Compare commits
No commits in common. "d3b03cecf295c660123841dae8f1fc5f288d849a" and "0da5936ce0a7b969f01b865c7fcf3060863acd73" have entirely different histories.
d3b03cecf2
...
0da5936ce0
@ -1,5 +1,4 @@
|
|||||||
import { PanelShell } from "@/components/panel-shell";
|
import { PanelShell } from "@/components/panel-shell";
|
||||||
import { ActivityChart } from "@/components/activity-chart";
|
|
||||||
import { getCaregiverActivity } from "@/lib/caregiver-panel";
|
import { getCaregiverActivity } from "@/lib/caregiver-panel";
|
||||||
import {
|
import {
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
@ -9,172 +8,105 @@ import {
|
|||||||
truncateText,
|
truncateText,
|
||||||
} from "@/lib/panel-format";
|
} from "@/lib/panel-format";
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
import { getCaregiverDisplayName } from "@/lib/session";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
function aggregateByDate(items: { createdAt: Date | string }[]) {
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
for (const item of items) {
|
|
||||||
const date = new Date(item.createdAt);
|
|
||||||
const key = `${date.getMonth() + 1}/${date.getDate()}`;
|
|
||||||
counts[key] = (counts[key] || 0) + 1;
|
|
||||||
}
|
|
||||||
return Object.entries(counts)
|
|
||||||
.map(([date, count]) => ({ date, count }))
|
|
||||||
.reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ActivityPage() {
|
export default async function ActivityPage() {
|
||||||
const caregiver = await requireCaregiverSession("/activity");
|
const caregiver = await requireCaregiverSession("/activity");
|
||||||
const { usageEvents, conversationTurns, toolCalls } = await getCaregiverActivity(
|
const { usageEvents, conversationTurns, toolCalls } = await getCaregiverActivity(
|
||||||
caregiver.id,
|
caregiver.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
const usageChartData = aggregateByDate(usageEvents);
|
|
||||||
const conversationChartData = aggregateByDate(conversationTurns);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
currentPath="/activity"
|
currentPath="/activity"
|
||||||
title="陪伴动态"
|
title="活动记录独立成页,回看陪伴过程更清楚。"
|
||||||
description="了解长辈最近的使用情况、聊天内容和智能服务记录。"
|
description="这里专门收下老人端的使用事件、最近对话片段和工具调用,不再和留言流混排。"
|
||||||
caregiverLabel={getCaregiverDisplayName(caregiver)}
|
caregiverToken={caregiver.sessionToken}
|
||||||
>
|
>
|
||||||
{usageEvents.length === 0 && conversationTurns.length === 0 && toolCalls.length === 0 ? (
|
{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)]">
|
<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 className="font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
暂时没有动态
|
还没有活动记录
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
||||||
长辈开始使用智能助理后,这里会自动记录使用情况和聊天内容,方便您随时了解。
|
等老人端开始对话、切换镜头或触发桥接工具后,这里会自动累积活动时间线。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<section className="grid gap-6 xl:grid-cols-3">
|
||||||
{(usageChartData.length > 1 || conversationChartData.length > 1) && (
|
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
||||||
<section className="grid gap-6 xl:grid-cols-2">
|
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
{usageChartData.length > 1 && (
|
使用事件
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
</p>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
使用频率
|
老人端操作轨迹
|
||||||
</p>
|
</h2>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
<div className="mt-5 space-y-3">
|
||||||
最近使用趋势
|
{usageEvents.map((event) => (
|
||||||
</h2>
|
<div key={event.id} className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
||||||
<div className="mt-4">
|
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
|
||||||
<ActivityChart data={usageChartData} color="#c76733" />
|
<span>{getDeviceName(event.elderDevice.displayName)}</span>
|
||||||
|
<span>{formatDateTime(event.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className="mt-2 text-sm leading-7 text-[var(--ink)]">
|
||||||
)}
|
{getUsageLabel(event.eventType)}
|
||||||
{conversationChartData.length > 1 && (
|
|
||||||
<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>
|
</p>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
</div>
|
||||||
最近对话趋势
|
))}
|
||||||
</h2>
|
</div>
|
||||||
<div className="mt-4">
|
</div>
|
||||||
<ActivityChart data={conversationChartData} color="#76845e" />
|
|
||||||
|
<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>
|
</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>
|
||||||
)}
|
))}
|
||||||
</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.length === 0 ? (
|
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
暂时没有使用记录。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
<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 className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
聊天记录
|
工具调用
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
最近的对话
|
function calling 执行记录
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-5 space-y-3">
|
||||||
{conversationTurns.length === 0 ? (
|
{toolCalls.map((log) => (
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
<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>
|
||||||
) : (
|
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
||||||
conversationTurns.map((turn) => (
|
{truncateText(log.outputText || log.argumentsJson, 136)}
|
||||||
<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)]">
|
|
||||||
助理做了什么
|
|
||||||
</h2>
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{toolCalls.length === 0 ? (
|
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
暂时没有智能服务记录。
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
</div>
|
||||||
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>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</>
|
</section>
|
||||||
)}
|
)}
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import {
|
|
||||||
loginCaregiverAccount,
|
|
||||||
sanitizeCaregiverRedirectPath,
|
|
||||||
} from "@/lib/session";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const body = (await request.json().catch(() => null)) as
|
|
||||||
| {
|
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
redirectTo?: string;
|
|
||||||
}
|
|
||||||
| null;
|
|
||||||
|
|
||||||
if (!body?.username || typeof body.username !== "string") {
|
|
||||||
return NextResponse.json({ error: "请先输入用户名。" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof body.password !== "string" || body.password.length === 0) {
|
|
||||||
return NextResponse.json({ error: "请先输入密码。" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await loginCaregiverAccount({
|
|
||||||
username: body.username,
|
|
||||||
password: body.password,
|
|
||||||
userAgent: request.headers.get("user-agent"),
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
redirectTo: sanitizeCaregiverRedirectPath(body.redirectTo),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: error instanceof Error ? error.message : "登录失败,请稍后再试。",
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import { clearCurrentCaregiverSession } from "@/lib/session";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
await clearCurrentCaregiverSession();
|
|
||||||
|
|
||||||
return NextResponse.redirect(new URL("/login", request.url), {
|
|
||||||
status: 303,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import {
|
|
||||||
registerCaregiverAccount,
|
|
||||||
sanitizeCaregiverRedirectPath,
|
|
||||||
} from "@/lib/session";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const body = (await request.json().catch(() => null)) as
|
|
||||||
| {
|
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
redirectTo?: string;
|
|
||||||
}
|
|
||||||
| null;
|
|
||||||
|
|
||||||
if (!body?.username || typeof body.username !== "string") {
|
|
||||||
return NextResponse.json({ error: "请先输入用户名。" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof body.password !== "string" || body.password.length === 0) {
|
|
||||||
return NextResponse.json({ error: "请先输入密码。" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await registerCaregiverAccount({
|
|
||||||
username: body.username,
|
|
||||||
password: body.password,
|
|
||||||
userAgent: request.headers.get("user-agent"),
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
redirectTo: sanitizeCaregiverRedirectPath(body.redirectTo),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
error instanceof Error ? error.message : "创建账号失败,请稍后再试。",
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { bindDeviceToCaregiver, createBindUrl } from "@/lib/monitor-data";
|
import { bindDeviceToCaregiver, createBindUrl } from "@/lib/monitor-data";
|
||||||
import { getCaregiverSession } from "@/lib/session";
|
import { getOrCreateCaregiverSession } from "@/lib/session";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const body = (await request.json().catch(() => null)) as
|
const body = (await request.json().catch(() => null)) as
|
||||||
@ -18,15 +18,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const caregiver = await getCaregiverSession();
|
const caregiver = await getOrCreateCaregiverSession();
|
||||||
|
|
||||||
if (!caregiver) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "请先登录账号后再绑定设备。" },
|
|
||||||
{ status: 401 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const device = await bindDeviceToCaregiver(caregiver.id, body.deviceUuid);
|
const device = await bindDeviceToCaregiver(caregiver.id, body.deviceUuid);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { createFamilyMessageFromCaregiver } from "@/lib/monitor-data";
|
import { createFamilyMessageFromCaregiver } from "@/lib/monitor-data";
|
||||||
import { getCaregiverSession } from "@/lib/session";
|
import { getOrCreateCaregiverSession } from "@/lib/session";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const body = (await request.json().catch(() => null)) as
|
const body = (await request.json().catch(() => null)) as
|
||||||
@ -27,15 +27,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const caregiver = await getCaregiverSession();
|
const caregiver = await getOrCreateCaregiverSession();
|
||||||
|
|
||||||
if (!caregiver) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "请先登录账号后再发送留言。" },
|
|
||||||
{ status: 401 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = await createFamilyMessageFromCaregiver({
|
const message = await createFamilyMessageFromCaregiver({
|
||||||
caregiverId: caregiver.id,
|
caregiverId: caregiver.id,
|
||||||
rawDeviceValue: body.deviceUuid,
|
rawDeviceValue: body.deviceUuid,
|
||||||
|
|||||||
@ -1,19 +1,49 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildCaregiverLoginPath,
|
CAREGIVER_SESSION_COOKIE,
|
||||||
|
createCaregiverSessionCookieValue,
|
||||||
getCaregiverSession,
|
getCaregiverSession,
|
||||||
sanitizeCaregiverRedirectPath,
|
|
||||||
} from "@/lib/session";
|
} 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) {
|
export async function GET(request: NextRequest) {
|
||||||
const redirectTo = sanitizeCaregiverRedirectPath(
|
const redirectTo = normalizeRedirectPath(
|
||||||
request.nextUrl.searchParams.get("redirectTo"),
|
request.nextUrl.searchParams.get("redirectTo"),
|
||||||
);
|
);
|
||||||
const caregiver = await getCaregiverSession();
|
const existingCaregiver = await getCaregiverSession();
|
||||||
const location = caregiver ? redirectTo : buildCaregiverLoginPath(redirectTo);
|
const response = new NextResponse(null, {
|
||||||
|
|
||||||
return NextResponse.redirect(new URL(location, request.url), {
|
|
||||||
status: 307,
|
status: 307,
|
||||||
|
headers: {
|
||||||
|
Location: redirectTo,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { getCaregiverSession } from "@/lib/session";
|
import { getOrCreateCaregiverSession } from "@/lib/session";
|
||||||
import { savePushSubscription } from "@/lib/push";
|
import { savePushSubscription } from "@/lib/push";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@ -50,15 +50,7 @@ export async function POST(request: Request) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const caregiver = await getCaregiverSession();
|
const caregiver = await getOrCreateCaregiverSession();
|
||||||
|
|
||||||
if (!caregiver) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "请先登录账号后再开启消息提醒。" },
|
|
||||||
{ status: 401 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await savePushSubscription({
|
await savePushSubscription({
|
||||||
caregiverId: caregiver.id,
|
caregiverId: caregiver.id,
|
||||||
subscription,
|
subscription,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
import { bindDeviceToCaregiver } from "@/lib/monitor-data";
|
import { bindDeviceToCaregiver } from "@/lib/monitor-data";
|
||||||
import {
|
import {
|
||||||
buildCaregiverLoginPath,
|
buildCaregiverSessionBootstrapPath,
|
||||||
getCaregiverSession,
|
getCaregiverSession,
|
||||||
} from "@/lib/session";
|
} from "@/lib/session";
|
||||||
|
|
||||||
@ -27,13 +27,13 @@ export default async function BindPage({ searchParams }: BindPageProps) {
|
|||||||
<main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6">
|
<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">
|
<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 className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
连接设备
|
连接家人设备
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
还没有收到设备信息
|
还没有收到设备码
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
|
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
|
||||||
请让长辈打开手机上的「家人连接」页面,把二维码给您扫一下,或将设备码复制过来。
|
请让老人打开家人连接页,把二维码给您扫一下,或者把设备码复制过来。
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex justify-center gap-3">
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
@ -46,7 +46,7 @@ export default async function BindPage({ searchParams }: BindPageProps) {
|
|||||||
href="/devices"
|
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"
|
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-white"
|
||||||
>
|
>
|
||||||
返回设备页
|
回到设备页
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -58,70 +58,29 @@ export default async function BindPage({ searchParams }: BindPageProps) {
|
|||||||
|
|
||||||
if (!caregiver) {
|
if (!caregiver) {
|
||||||
redirect(
|
redirect(
|
||||||
buildCaregiverLoginPath(
|
buildCaregiverSessionBootstrapPath(
|
||||||
`/bind?deviceUuid=${encodeURIComponent(rawDeviceValue)}`,
|
`/bind?deviceUuid=${encodeURIComponent(rawDeviceValue)}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let device: Awaited<ReturnType<typeof bindDeviceToCaregiver>> | null = null;
|
const device = await bindDeviceToCaregiver(caregiver.id, rawDeviceValue);
|
||||||
let bindError: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
device = await bindDeviceToCaregiver(caregiver.id, rawDeviceValue);
|
|
||||||
} catch (error) {
|
|
||||||
bindError =
|
|
||||||
error instanceof Error ? error.message : "绑定失败,请稍后再试。";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!device) {
|
|
||||||
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)]">
|
|
||||||
{bindError || "请重新扫码,或请长辈重新打开设备连接页面后再试一次。"}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<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(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
|
||||||
>
|
|
||||||
重新扫码
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
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>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6">
|
<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">
|
<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 className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
连接成功
|
绑定完成
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
已成功连接长辈的设备
|
这位老人已经连到当前家属账号
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
|
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
|
||||||
现在可以查看长辈的留言、了解使用动态,也可以随时给长辈写一句问候。
|
现在您可以在控制台查看老人给家里的留言、最近陪伴记录,也能马上给老人写下一句问候。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 rounded-[28px] bg-[var(--paper-soft)] p-5">
|
<div className="mt-6 rounded-[28px] bg-[var(--paper-soft)] p-5">
|
||||||
<p className="text-sm text-[var(--muted)]">设备标识</p>
|
<p className="text-sm text-[var(--muted)]">设备 UUID</p>
|
||||||
<p className="mt-2 break-all text-lg font-semibold text-[var(--ink)]">
|
<p className="mt-2 break-all text-lg font-semibold text-[var(--ink)]">
|
||||||
{device.deviceUuid}
|
{device.deviceUuid}
|
||||||
</p>
|
</p>
|
||||||
@ -132,13 +91,13 @@ export default async function BindPage({ searchParams }: BindPageProps) {
|
|||||||
href={`/devices/${device.deviceUuid}`}
|
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)]"
|
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
||||||
>
|
>
|
||||||
查看设备详情
|
打开这台设备页
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/devices"
|
href="/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)]"
|
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
||||||
>
|
>
|
||||||
返回设备列表
|
查看全部设备
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import {
|
|||||||
truncateText,
|
truncateText,
|
||||||
} from "@/lib/panel-format";
|
} from "@/lib/panel-format";
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
import { getCaregiverDisplayName } from "@/lib/session";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@ -42,18 +41,18 @@ export default async function DeviceDetailPage({ params }: DeviceDetailPageProps
|
|||||||
<PanelShell
|
<PanelShell
|
||||||
currentPath="/devices"
|
currentPath="/devices"
|
||||||
title={getDeviceName(binding.elderDevice.displayName)}
|
title={getDeviceName(binding.elderDevice.displayName)}
|
||||||
description="给长辈写问候、查看留言和最近的使用动态。"
|
description="这是一台设备的专属页面。给老人写问候、查看这台设备最近的留言、活动和对话,都在这里完成。"
|
||||||
caregiverLabel={getCaregiverDisplayName(caregiver)}
|
caregiverToken={caregiver.sessionToken}
|
||||||
>
|
>
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<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">
|
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
||||||
<p className="text-sm text-[var(--muted)]">新留言</p>
|
<p className="text-sm text-[var(--muted)]">老人新留言</p>
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
{binding.elderUnreadCount}
|
{binding.elderUnreadCount}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
||||||
<p className="text-sm text-[var(--muted)]">待查看</p>
|
<p className="text-sm text-[var(--muted)]">待老人查看</p>
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
{binding.familyUnreadCount}
|
{binding.familyUnreadCount}
|
||||||
</p>
|
</p>
|
||||||
@ -65,7 +64,7 @@ export default async function DeviceDetailPage({ params }: DeviceDetailPageProps
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
||||||
<p className="text-sm text-[var(--muted)]">设备链接</p>
|
<p className="text-sm text-[var(--muted)]">绑定链接</p>
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
||||||
{truncateText(binding.bindUrl, 52)}
|
{truncateText(binding.bindUrl, 52)}
|
||||||
</p>
|
</p>
|
||||||
@ -80,24 +79,24 @@ export default async function DeviceDetailPage({ params }: DeviceDetailPageProps
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
长辈的话
|
老人给家里的话
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
最近收到的留言
|
最近通过桥接工具送出的内容
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/messages"
|
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)]"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-5 space-y-3">
|
||||||
{elderMessages.length === 0 ? (
|
{elderMessages.length === 0 ? (
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
||||||
还没有收到长辈的留言。
|
这台设备暂时还没有新的老人留言。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
elderMessages.map((message) => (
|
elderMessages.map((message) => (
|
||||||
@ -127,13 +126,13 @@ export default async function DeviceDetailPage({ params }: DeviceDetailPageProps
|
|||||||
最近活动
|
最近活动
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
最近的使用情况
|
这台设备刚刚发生了什么
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-5 space-y-3">
|
||||||
{binding.elderDevice.usageEvents.length === 0 ? (
|
{binding.elderDevice.usageEvents.length === 0 ? (
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
||||||
暂时还没有使用记录。
|
暂时还没有同步到新的使用事件。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
binding.elderDevice.usageEvents.map((event) => (
|
binding.elderDevice.usageEvents.map((event) => (
|
||||||
@ -152,16 +151,16 @@ export default async function DeviceDetailPage({ params }: DeviceDetailPageProps
|
|||||||
<div className="space-y-6">
|
<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)]">
|
<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 className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
我的问候
|
家属给老人的问候
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
已发出的留言
|
最近写给这位老人的内容
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-5 space-y-3">
|
||||||
{familyMessages.length === 0 ? (
|
{familyMessages.length === 0 ? (
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
||||||
还没有给这位长辈写过留言。
|
还没有发给这位老人的问候。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
familyMessages.map((message) => (
|
familyMessages.map((message) => (
|
||||||
@ -191,13 +190,13 @@ export default async function DeviceDetailPage({ params }: DeviceDetailPageProps
|
|||||||
最近对话片段
|
最近对话片段
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
最近的聊天记录
|
快速回看陪伴内容
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-5 space-y-3">
|
||||||
{binding.elderDevice.conversationTurns.length === 0 ? (
|
{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 className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
||||||
还没有聊天记录。
|
这台设备最近还没有同步到对话片段。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
binding.elderDevice.conversationTurns.map((turn) => (
|
binding.elderDevice.conversationTurns.map((turn) => (
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { PwaControls } from "@/components/pwa-controls";
|
|||||||
import { getCaregiverDevices } from "@/lib/caregiver-panel";
|
import { getCaregiverDevices } from "@/lib/caregiver-panel";
|
||||||
import { formatDateTime, getDeviceName, truncateText } from "@/lib/panel-format";
|
import { formatDateTime, getDeviceName, truncateText } from "@/lib/panel-format";
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
import { getCaregiverDisplayName } from "@/lib/session";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@ -17,9 +16,9 @@ export default async function DevicesPage() {
|
|||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
currentPath="/devices"
|
currentPath="/devices"
|
||||||
title="我的设备"
|
title="设备管理和绑定入口单独放在这里。"
|
||||||
description="管理已连接的长辈设备,点击进入设备详情查看留言和使用动态。"
|
description="手动粘贴设备码、扫码绑定、查看某台设备的专属页,都统一放在设备页,不再和首页摘要、活动记录混在一起。"
|
||||||
caregiverLabel={getCaregiverDisplayName(caregiver)}
|
caregiverToken={caregiver.sessionToken}
|
||||||
actions={<PwaControls />}
|
actions={<PwaControls />}
|
||||||
>
|
>
|
||||||
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
@ -27,15 +26,15 @@ export default async function DevicesPage() {
|
|||||||
|
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
<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 className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
使用说明
|
设备入口说明
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
如何连接长辈的设备?
|
一台设备一个详情页
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-4 space-y-3 rounded-[24px] bg-[var(--paper-soft)] p-5 text-sm leading-7 text-[var(--muted)]">
|
<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>1. 先通过扫码或粘贴设备码建立连接。</p>
|
||||||
<p>2. 您用自己的手机扫描这个二维码,或将设备码粘贴到左侧输入框。</p>
|
<p>2. 进入设备详情页给这位老人写留言,不再在首页里混排。</p>
|
||||||
<p>3. 连接成功后,即可在这里查看长辈的留言、对话和使用动态。</p>
|
<p>3. 每台设备都有自己的最近留言、对话片段和工具调用摘要。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
@ -43,13 +42,13 @@ export default async function DevicesPage() {
|
|||||||
href="/scan"
|
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)]"
|
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>
|
||||||
<Link
|
<Link
|
||||||
href="/messages"
|
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)]"
|
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
||||||
>
|
>
|
||||||
查看留言
|
去留言中心
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -58,10 +57,10 @@ export default async function DevicesPage() {
|
|||||||
{devices.length === 0 ? (
|
{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)]">
|
<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 className="font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
还没有连接设备
|
还没有绑定设备
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
||||||
扫描长辈手机上的二维码,或者在上方粘贴设备码,即可开始接收长辈的留言和动态。
|
等待绑定成功后,每台设备都会在这里生成自己的专属入口,后续写留言和查看状态都从设备详情页进入。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
@ -75,7 +74,7 @@ export default async function DevicesPage() {
|
|||||||
className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]"
|
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 className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
已连接
|
设备详情
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
{getDeviceName(binding.elderDevice.displayName)}
|
{getDeviceName(binding.elderDevice.displayName)}
|
||||||
@ -86,28 +85,28 @@ export default async function DevicesPage() {
|
|||||||
最近在线:{formatDateTime(binding.elderDevice.lastSeenAt)}
|
最近在线:{formatDateTime(binding.elderDevice.lastSeenAt)}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
||||||
新留言 {binding.elderUnreadCount}
|
老人新留言 {binding.elderUnreadCount}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
||||||
待查看 {binding.familyUnreadCount}
|
待老人查看 {binding.familyUnreadCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||||
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
||||||
<p className="text-xs text-[var(--muted)]">留言</p>
|
<p className="text-xs text-[var(--muted)]">留言总数</p>
|
||||||
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
||||||
{binding.elderDevice._count.messages}
|
{binding.elderDevice._count.messages}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
||||||
<p className="text-xs text-[var(--muted)]">对话</p>
|
<p className="text-xs text-[var(--muted)]">对话条数</p>
|
||||||
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
||||||
{binding.elderDevice._count.conversationTurns}
|
{binding.elderDevice._count.conversationTurns}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
||||||
<p className="text-xs text-[var(--muted)]">智能服务</p>
|
<p className="text-xs text-[var(--muted)]">工具调用</p>
|
||||||
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
||||||
{binding.elderDevice._count.toolCalls}
|
{binding.elderDevice._count.toolCalls}
|
||||||
</p>
|
</p>
|
||||||
@ -115,11 +114,11 @@ export default async function DevicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
<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="text-sm font-semibold text-[var(--ink)]">最近一条动态</p>
|
||||||
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
||||||
{latestMessage
|
{latestMessage
|
||||||
? truncateText(latestMessage.content, 96)
|
? truncateText(latestMessage.content, 96)
|
||||||
: "还没有收到新的留言或问候。"}
|
: "这台设备还没有同步到新的留言或问候。"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -128,13 +127,13 @@ export default async function DevicesPage() {
|
|||||||
href={`/devices/${binding.elderDevice.deviceUuid}`}
|
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)]"
|
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
||||||
>
|
>
|
||||||
查看详情
|
打开设备页
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/messages"
|
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)]"
|
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
||||||
>
|
>
|
||||||
查看留言
|
去留言中心
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@ -38,6 +38,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ const displayFont = Noto_Serif_SC({
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL("https://digital-human.xn--876a.net"),
|
metadataBase: new URL("https://digital-human.xn--876a.net"),
|
||||||
title: "家人连线台",
|
title: "家人连线台",
|
||||||
description: "随时了解长辈的近况,查看留言、使用动态,给长辈写问候。",
|
description: "给老人设备做绑定、查看留言、追踪陪伴记录的家属端。",
|
||||||
manifest: "/manifest.webmanifest",
|
manifest: "/manifest.webmanifest",
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
|
|||||||
@ -1,79 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
import { AccountAuthForm } from "@/components/account-auth-form";
|
|
||||||
import {
|
|
||||||
getCaregiverSession,
|
|
||||||
sanitizeCaregiverRedirectPath,
|
|
||||||
} from "@/lib/session";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type LoginPageProps = {
|
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|
||||||
const caregiver = await getCaregiverSession();
|
|
||||||
const params = await searchParams;
|
|
||||||
const redirectTo = sanitizeCaregiverRedirectPath(
|
|
||||||
typeof params.redirectTo === "string" ? params.redirectTo : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (caregiver) {
|
|
||||||
redirect(redirectTo);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="mx-auto flex min-h-screen max-w-6xl items-center px-4 py-8 sm:px-6 lg:px-8">
|
|
||||||
<section className="grid w-full gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
|
||||||
<div className="relative overflow-hidden rounded-[40px] border border-white/75 bg-[linear-gradient(160deg,rgba(53,84,141,0.95),rgba(28,45,80,0.94))] p-7 text-white shadow-[0_36px_90px_rgba(53,84,141,0.24)] sm:p-8">
|
|
||||||
<div className="absolute -right-10 top-[-40px] h-44 w-44 rounded-full bg-white/10 blur-2xl" />
|
|
||||||
<div className="absolute bottom-[-48px] left-[-12px] h-40 w-40 rounded-full border border-white/16" />
|
|
||||||
|
|
||||||
<p className="relative text-sm font-semibold tracking-[0.22em] text-white/78 uppercase">
|
|
||||||
家人连线
|
|
||||||
</p>
|
|
||||||
<h1 className="relative mt-5 font-[family-name:var(--font-display)] text-4xl leading-[1.18] sm:text-5xl">
|
|
||||||
用同一个账号,
|
|
||||||
<br />
|
|
||||||
在每一台家属设备上继续守护
|
|
||||||
</h1>
|
|
||||||
<p className="relative mt-5 max-w-2xl text-base leading-8 text-white/78 sm:text-lg">
|
|
||||||
现在开始,设备连接、留言记录和消息提醒都按账号归属。您在手机、平板或电脑上登录同一个账号,长辈发来的新留言都会一起送达。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="relative mt-8 grid gap-3">
|
|
||||||
{[
|
|
||||||
"绑定过的长辈设备会跟随账号保留,不再分散到不同浏览器会话。",
|
|
||||||
"一台设备开了提醒,另一台设备也登录同一个账号时,同样可以独立接收推送。",
|
|
||||||
"登录后会自动回到刚才的页面,扫码和绑定流程不用重新开始。",
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item}
|
|
||||||
className="rounded-[22px] border border-white/12 bg-white/8 px-5 py-4 text-sm leading-7 text-white/84"
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/80 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)] sm:p-7">
|
|
||||||
<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="mt-3 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
如果您刚刚在扫码或添加设备,登录完成后会自动回到原来的页面,不需要重新输入。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AccountAuthForm redirectTo={redirectTo} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,7 +4,7 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
return {
|
return {
|
||||||
name: "家人连线台",
|
name: "家人连线台",
|
||||||
short_name: "家人连线台",
|
short_name: "家人连线台",
|
||||||
description: "随时了解长辈的近况,查看留言、使用动态,给长辈写问候。",
|
description: "给老人设备做绑定、查看留言、追踪陪伴记录的家属端。",
|
||||||
start_url: "/",
|
start_url: "/",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
background_color: "#f6eee1",
|
background_color: "#f6eee1",
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import {
|
|||||||
getMessageStatusLabel,
|
getMessageStatusLabel,
|
||||||
} from "@/lib/panel-format";
|
} from "@/lib/panel-format";
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
import { getCaregiverDisplayName } from "@/lib/session";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@ -38,8 +37,8 @@ export default async function MessageDetailPage({ params }: MessageDetailPagePro
|
|||||||
<PanelShell
|
<PanelShell
|
||||||
currentPath="/messages"
|
currentPath="/messages"
|
||||||
title={`留言 #${message.publicId}`}
|
title={`留言 #${message.publicId}`}
|
||||||
description="查看这条留言的完整内容和设备信息。"
|
description="这是单条留言详情页。无论从通知点击进来,还是从留言中心点开,都能在这里看到完整内容与所属设备信息。"
|
||||||
caregiverLabel={getCaregiverDisplayName(caregiver)}
|
caregiverToken={caregiver.sessionToken}
|
||||||
>
|
>
|
||||||
<section className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
<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)]">
|
<article className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
||||||
@ -71,7 +70,7 @@ export default async function MessageDetailPage({ params }: MessageDetailPagePro
|
|||||||
|
|
||||||
<aside className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
<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 className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
设备信息
|
留言信息
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-5 space-y-4 text-sm leading-7 text-[var(--muted)]">
|
<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">
|
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
||||||
@ -81,7 +80,7 @@ export default async function MessageDetailPage({ params }: MessageDetailPagePro
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
||||||
<p>设备标识</p>
|
<p>设备 UUID</p>
|
||||||
<p className="mt-2 break-all font-semibold text-[var(--ink)]">
|
<p className="mt-2 break-all font-semibold text-[var(--ink)]">
|
||||||
{message.elderDevice.deviceUuid}
|
{message.elderDevice.deviceUuid}
|
||||||
</p>
|
</p>
|
||||||
@ -105,13 +104,13 @@ export default async function MessageDetailPage({ params }: MessageDetailPagePro
|
|||||||
href="/messages"
|
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)]"
|
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
||||||
>
|
>
|
||||||
返回留言板
|
回到留言中心
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/devices/${message.elderDevice.deviceUuid}`}
|
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)]"
|
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
||||||
>
|
>
|
||||||
查看设备
|
打开这台设备页
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import {
|
|||||||
truncateText,
|
truncateText,
|
||||||
} from "@/lib/panel-format";
|
} from "@/lib/panel-format";
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
import { getCaregiverDisplayName } from "@/lib/session";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@ -32,9 +31,9 @@ export default async function MessagesPage() {
|
|||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
currentPath="/messages"
|
currentPath="/messages"
|
||||||
title="留言板"
|
title="留言独立成页后,每条消息都能用编号直达。"
|
||||||
description="长辈捆来的话和您发出的问候,都在这里。"
|
description="不论是老人留给家里的话,还是家属写给老人的问候,都集中在这里。点击任何一条卡片,都会进入带 URL 编号的详情页。"
|
||||||
caregiverLabel={getCaregiverDisplayName(caregiver)}
|
caregiverToken={caregiver.sessionToken}
|
||||||
actions={<PwaControls />}
|
actions={<PwaControls />}
|
||||||
>
|
>
|
||||||
<section className="grid gap-4 md:grid-cols-3">
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
@ -45,13 +44,13 @@ export default async function MessagesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
||||||
<p className="text-sm text-[var(--muted)]">长辈的未读留言</p>
|
<p className="text-sm text-[var(--muted)]">老人给家里的未读留言</p>
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
{unreadIncomingCount}
|
{unreadIncomingCount}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
||||||
<p className="text-sm text-[var(--muted)]">待长辈查看</p>
|
<p className="text-sm text-[var(--muted)]">待老人查看的家属留言</p>
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
{unreadOutgoingCount}
|
{unreadOutgoingCount}
|
||||||
</p>
|
</p>
|
||||||
@ -61,23 +60,23 @@ export default async function MessagesPage() {
|
|||||||
{messages.length === 0 ? (
|
{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)]">
|
<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 className="font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
还没有留言
|
还没有留言记录
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
||||||
连接长辈的设备后,就可以在这里查看长辈的留言,也可以给长辈写问候。
|
去设备页给老人留一句问候,或等待老人通过桥接工具给家里捎话;新记录会自动出现在这里,并带有可分享的消息编号。
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex justify-center gap-3">
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
href="/devices"
|
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)]"
|
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
||||||
>
|
>
|
||||||
连接设备
|
去设备页
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/scan"
|
href="/scan"
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
||||||
>
|
>
|
||||||
扫码连接
|
去扫码绑定
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -85,13 +84,13 @@ export default async function MessagesPage() {
|
|||||||
<section className="grid gap-6 xl:grid-cols-2">
|
<section className="grid gap-6 xl:grid-cols-2">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
title: "长辈的留言",
|
title: "老人给家里的留言",
|
||||||
subtitle: "长辈通过智能助理捆来的话。",
|
subtitle: "收到新话时也会从这里直达详情页。",
|
||||||
items: incomingMessages,
|
items: incomingMessages,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "我发出的问候",
|
title: "家属给老人的问候",
|
||||||
subtitle: "您写给长辈的每一条问候。",
|
subtitle: "写出去的每条问候也有独立 URL,可回看送达状态。",
|
||||||
items: outgoingMessages,
|
items: outgoingMessages,
|
||||||
},
|
},
|
||||||
].map((section) => (
|
].map((section) => (
|
||||||
@ -100,7 +99,7 @@ export default async function MessagesPage() {
|
|||||||
className="rounded-[32px] border border-white/75 bg-white/80 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]"
|
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 className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
{section.title === "长辈的留言" ? "收到的" : "发出的"}
|
留言列表
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
{section.title}
|
{section.title}
|
||||||
|
|||||||
100
app/page.tsx
100
app/page.tsx
@ -1,5 +1,4 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { MessageCircle, Smartphone, Activity, ScanLine } from "lucide-react";
|
|
||||||
|
|
||||||
import { PanelShell } from "@/components/panel-shell";
|
import { PanelShell } from "@/components/panel-shell";
|
||||||
import { PwaControls } from "@/components/pwa-controls";
|
import { PwaControls } from "@/components/pwa-controls";
|
||||||
@ -12,7 +11,6 @@ import {
|
|||||||
truncateText,
|
truncateText,
|
||||||
} from "@/lib/panel-format";
|
} from "@/lib/panel-format";
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
import { getCaregiverDisplayName } from "@/lib/session";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@ -37,32 +35,32 @@ export default async function Home() {
|
|||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
currentPath="/"
|
currentPath="/"
|
||||||
title="今日概览"
|
title="首页只保留摘要,不再把所有操作挤在一处。"
|
||||||
description="随时了解长辈的近况,留言和动态一目了然。"
|
description="设备绑定、留言管理、活动回看和扫码入口已经拆到独立路径。这里保留今天最值得先看的摘要,以及最近的新留言。"
|
||||||
caregiverLabel={getCaregiverDisplayName(dashboard.caregiver)}
|
caregiverToken={dashboard.caregiver.sessionToken}
|
||||||
actions={<PwaControls />}
|
actions={<PwaControls />}
|
||||||
>
|
>
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<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)]">
|
<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="text-sm text-[var(--muted)]">已连接老人设备</p>
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
{dashboard.devices.length}
|
{dashboard.devices.length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
||||||
<p className="text-sm text-[var(--muted)]">长辈的新留言</p>
|
<p className="text-sm text-[var(--muted)]">老人给家里的未读留言</p>
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
{totalUnreadForCaregiver}
|
{totalUnreadForCaregiver}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
||||||
<p className="text-sm text-[var(--muted)]">待长辈查看</p>
|
<p className="text-sm text-[var(--muted)]">待老人查看的家属留言</p>
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
{totalUnreadForElder}
|
{totalUnreadForElder}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
||||||
<p className="text-sm text-[var(--muted)]">陪伴对话</p>
|
<p className="text-sm text-[var(--muted)]">最近存档对话条数</p>
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
{totalConversations}
|
{totalConversations}
|
||||||
</p>
|
</p>
|
||||||
@ -73,77 +71,69 @@ export default async function Home() {
|
|||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
href: "/messages",
|
href: "/messages",
|
||||||
title: "留言板",
|
title: "留言中心",
|
||||||
text: "查看长辈捎来的话,也能回看您发出的每一条问候。",
|
text: "集中看所有老人留言和家属留言,每条消息都有 URL 编号可直接打开。",
|
||||||
tone: "bg-[rgba(53,84,141,0.12)]",
|
tone: "bg-[rgba(53,84,141,0.12)]",
|
||||||
icon: MessageCircle,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/devices",
|
href: "/devices",
|
||||||
title: "我的设备",
|
title: "设备与绑定",
|
||||||
text: "管理已绑定的长辈设备,进入设备详情给长辈写留言。",
|
text: "去管理设备、扫码绑定、查看某一台设备的专属详情页和留言入口。",
|
||||||
tone: "bg-[rgba(118,132,94,0.14)]",
|
tone: "bg-[rgba(118,132,94,0.14)]",
|
||||||
icon: Smartphone,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/activity",
|
href: "/activity",
|
||||||
title: "陪伴动态",
|
title: "活动回看",
|
||||||
text: "了解长辈最近的使用情况和聊天记录,安心守护。",
|
text: "把使用事件、工具调用和对话片段拆出来,回看时不再和留言混在一起。",
|
||||||
tone: "bg-[rgba(199,103,51,0.12)]",
|
tone: "bg-[rgba(199,103,51,0.12)]",
|
||||||
icon: Activity,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/scan",
|
href: "/scan",
|
||||||
title: "扫码绑定",
|
title: "扫码绑定",
|
||||||
text: "扫描长辈手机上的二维码,一步完成设备连接。",
|
text: "拿起另一台手机时,直接走扫码页,不必再回首页寻找入口。",
|
||||||
tone: "bg-[rgba(36,27,20,0.08)]",
|
tone: "bg-[rgba(36,27,20,0.08)]",
|
||||||
icon: ScanLine,
|
|
||||||
},
|
},
|
||||||
].map((item) => {
|
].map((item) => (
|
||||||
const Icon = item.icon;
|
<Link
|
||||||
return (
|
key={item.href}
|
||||||
<Link
|
href={item.href}
|
||||||
key={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"
|
||||||
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 className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-semibold text-[var(--ink)] ${item.tone}`}>
|
</div>
|
||||||
<Icon size={14} />
|
<h2 className="mt-4 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
||||||
{item.title}
|
{item.title}
|
||||||
</div>
|
</h2>
|
||||||
<h2 className="mt-4 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">{item.text}</p>
|
||||||
{item.title}
|
<p className="mt-4 text-sm font-semibold text-[var(--copper)]">前往 {item.href}</p>
|
||||||
</h2>
|
</Link>
|
||||||
<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)]">查看详情 →</p>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{dashboard.devices.length === 0 ? (
|
{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)]">
|
<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 className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
开始使用
|
还没有绑定老人设备
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-3 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<h2 className="mt-3 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
先连接长辈的设备
|
先去设备页或扫码页建立第一条连接
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
||||||
让长辈打开手机里的「家人连接」页面,用您的手机扫描二维码,即可建立连接。连接成功后,您就能收到长辈的留言和使用动态。
|
绑定完成后,留言中心会出现 URL 编号消息卡,活动页会出现陪伴记录,推送也能直达具体留言。
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex justify-center gap-3">
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
href="/scan"
|
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)]"
|
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
||||||
>
|
>
|
||||||
扫码连接
|
去设备页
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/devices"
|
href="/scan"
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
||||||
>
|
>
|
||||||
手动添加
|
直接扫码
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -153,24 +143,24 @@ export default async function Home() {
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
长辈的留言
|
新收到的老人留言
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
最近收到的话
|
点一下就能按编号直达
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/messages"
|
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)]"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-5 space-y-3">
|
||||||
{recentIncomingMessages.length === 0 ? (
|
{recentIncomingMessages.length === 0 ? (
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
||||||
暂时还没有收到新留言。
|
目前还没有新的老人留言。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
recentIncomingMessages.map((message) => (
|
recentIncomingMessages.map((message) => (
|
||||||
@ -200,24 +190,24 @@ export default async function Home() {
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
我发出的问候
|
最近发给老人的问候
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
最近写给长辈的话
|
发出后也能按编号回看
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/devices"
|
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)]"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-5 space-y-3">
|
||||||
{recentOutgoingMessages.length === 0 ? (
|
{recentOutgoingMessages.length === 0 ? (
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
||||||
还没有给长辈留过言,去设备页写一句问候吧。
|
还没有新的家属留言,可以到设备页针对某一台设备写下问候。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
recentOutgoingMessages.map((message) => (
|
recentOutgoingMessages.map((message) => (
|
||||||
|
|||||||
@ -8,19 +8,19 @@ export default function ScanPage() {
|
|||||||
<section className="grid gap-6 lg:grid-cols-[0.82fr_1.18fr]">
|
<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">
|
<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 className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
扫码连接
|
扫码绑定
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl leading-[1.15] text-[var(--ink)]">
|
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl leading-[1.15] text-[var(--ink)]">
|
||||||
对准长辈手机上的二维码
|
对准老人手机里的家人连接二维码
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
|
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
|
||||||
扫描成功后,系统会自动将长辈的设备连接到您的账号。之后就能随时查看长辈的留言和使用动态。
|
扫描成功后,系统会自动把老人的本机 UUID 绑定到当前家属账号。之后,您就能看见老人的留言、最近对话和使用记录。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 space-y-3 rounded-[28px] bg-[var(--paper-soft)] p-5 text-sm leading-7 text-[var(--muted)]">
|
<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>1. 让老人打开安卓端的家人连接页。</p>
|
||||||
<p>2. 将镜头对准屏幕上的二维码,保持一两秒即可。</p>
|
<p>2. 把镜头对准屏幕上的二维码,停稳一两秒。</p>
|
||||||
<p>3. 连接成功后会自动跳转。</p>
|
<p>3. 绑定成功后会自动跳回控制台。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
@ -28,7 +28,7 @@ export default function ScanPage() {
|
|||||||
href="/devices"
|
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)]"
|
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
||||||
>
|
>
|
||||||
返回设备页
|
回到设备页
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,189 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { startTransition, useState } from "react";
|
|
||||||
|
|
||||||
type AccountAuthFormProps = {
|
|
||||||
redirectTo: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AuthMode = "login" | "register";
|
|
||||||
|
|
||||||
export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [mode, setMode] = useState<AuthMode>("login");
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (!username.trim()) {
|
|
||||||
setNotice("请先输入用户名。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!password) {
|
|
||||||
setNotice("请先输入密码。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === "register" && password !== confirmPassword) {
|
|
||||||
setNotice("两次输入的密码还不一致,请再检查一次。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
setNotice(mode === "login" ? "正在登录账号……" : "正在创建账号……");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
mode === "login" ? "/api/account/login" : "/api/account/register",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: username.trim(),
|
|
||||||
password,
|
|
||||||
redirectTo,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const payload = (await response.json().catch(() => null)) as
|
|
||||||
| { error?: string; redirectTo?: string }
|
|
||||||
| null;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
payload?.error ||
|
|
||||||
(mode === "login"
|
|
||||||
? "登录失败,请稍后再试。"
|
|
||||||
: "创建账号失败,请稍后再试。"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setNotice(mode === "login" ? "登录成功,正在进入……" : "账号已创建,正在进入……");
|
|
||||||
startTransition(() => {
|
|
||||||
router.replace(payload?.redirectTo || redirectTo);
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setNotice(
|
|
||||||
error instanceof Error ? error.message : "操作失败,请稍后再试。",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/84 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)] backdrop-blur sm:p-7">
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{[
|
|
||||||
{ id: "login", label: "已有账号,直接登录" },
|
|
||||||
{ id: "register", label: "第一次使用,创建账号" },
|
|
||||||
].map((item) => {
|
|
||||||
const active = mode === item.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setMode(item.id as AuthMode);
|
|
||||||
setNotice(null);
|
|
||||||
}}
|
|
||||||
className={`inline-flex h-11 items-center justify-center rounded-full px-4 text-sm font-semibold transition ${
|
|
||||||
active
|
|
||||||
? "bg-[var(--ink)] text-white shadow-[0_12px_30px_rgba(36,27,20,0.18)]"
|
|
||||||
: "border border-[var(--line)] bg-white text-[var(--ink)] hover:bg-[var(--paper-soft)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-semibold text-[var(--ink)]" htmlFor="caregiver-username">
|
|
||||||
用户名
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="caregiver-username"
|
|
||||||
value={username}
|
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
placeholder="例如:family_guardian"
|
|
||||||
className="mt-2 h-13 w-full rounded-[22px] border border-[var(--line)] bg-[var(--paper-soft)] px-4 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
|
|
||||||
/>
|
|
||||||
<p className="mt-2 text-xs leading-6 text-[var(--muted)]">
|
|
||||||
建议使用 3 到 24 位字母、数字、下划线或横线,方便全家统一登录。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-semibold text-[var(--ink)]" htmlFor="caregiver-password">
|
|
||||||
密码
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="caregiver-password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
|
||||||
placeholder="请输入密码"
|
|
||||||
className="mt-2 h-13 w-full rounded-[22px] border border-[var(--line)] bg-[var(--paper-soft)] px-4 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
|
|
||||||
/>
|
|
||||||
<p className="mt-2 text-xs leading-6 text-[var(--muted)]">
|
|
||||||
密码至少 8 位。同一账号可以在多台家属设备登录,消息提醒会同步到所有已登录设备。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mode === "register" ? (
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-semibold text-[var(--ink)]" htmlFor="caregiver-password-confirm">
|
|
||||||
再输入一次密码
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="caregiver-password-confirm"
|
|
||||||
type="password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
placeholder="再次输入密码"
|
|
||||||
className="mt-2 h-13 w-full rounded-[22px] border border-[var(--line)] bg-[var(--paper-soft)] px-4 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="inline-flex h-12 w-full items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{submitting
|
|
||||||
? mode === "login"
|
|
||||||
? "正在登录"
|
|
||||||
: "正在创建账号"
|
|
||||||
: mode === "login"
|
|
||||||
? "登录并继续"
|
|
||||||
: "创建账号并继续"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{notice ? (
|
|
||||||
<p className="mt-4 rounded-[20px] bg-[var(--paper-soft)] px-4 py-3 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
{notice}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
CartesianGrid,
|
|
||||||
} from "recharts";
|
|
||||||
|
|
||||||
type DailyCount = {
|
|
||||||
date: string;
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ActivityChartProps = {
|
|
||||||
data: DailyCount[];
|
|
||||||
color?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ActivityChart({ data, color = "var(--copper)" }: ActivityChartProps) {
|
|
||||||
if (data.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-48 w-full">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(85,60,42,0.08)" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
tick={{ fontSize: 11, fill: "#726153" }}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fontSize: 11, fill: "#726153" }}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
allowDecimals={false}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
borderRadius: 16,
|
|
||||||
border: "1px solid rgba(85,60,42,0.12)",
|
|
||||||
background: "rgba(255,253,248,0.96)",
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: "#241b14", fontWeight: 600 }}
|
|
||||||
formatter={(value) => [`${Number(value ?? 0)} 次`, "次数"]}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="count"
|
|
||||||
fill={color}
|
|
||||||
radius={[6, 6, 0, 0]}
|
|
||||||
maxBarSize={32}
|
|
||||||
/>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -14,16 +14,42 @@ export function BindDeviceForm() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!rawCode.trim()) {
|
if (!rawCode.trim()) {
|
||||||
setNotice("请先粘贴长辈手机上的设备码。\n");
|
setNotice("先把老人二维码里的设备码贴进来。\n");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setNotice("正在前往绑定页面,请稍候……");
|
setNotice("正在建立连接,请稍候……");
|
||||||
|
|
||||||
startTransition(() => {
|
try {
|
||||||
router.push(`/bind?deviceUuid=${encodeURIComponent(rawCode.trim())}`);
|
const response = await fetch("/api/family/bind", {
|
||||||
});
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ deviceUuid: rawCode }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as
|
||||||
|
| { error?: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload?.error || "连接失败,请稍后再试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotice("已经连上了,这位老人的留言和陪伴记录会出现在下方。\n");
|
||||||
|
setRawCode("");
|
||||||
|
startTransition(() => {
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(
|
||||||
|
error instanceof Error ? error.message : "连接失败,请稍后再试。",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -31,20 +57,20 @@ export function BindDeviceForm() {
|
|||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
添加设备
|
家人连接
|
||||||
</p>
|
</p>
|
||||||
<h3 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
<h3 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
||||||
扫码或粘贴设备码连接长辈
|
扫二维码,或者直接贴入设备码
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
||||||
让长辈打开手机上的「家人连接」页面,扫描二维码最快;也可以将设备码粘贴到下方。
|
如果老人已经把二维码发给您,直接扫码最快;如果您拿到的是一段链接或设备码,也可以贴到下面。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/scan"
|
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)]"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -52,7 +78,7 @@ export function BindDeviceForm() {
|
|||||||
<input
|
<input
|
||||||
value={rawCode}
|
value={rawCode}
|
||||||
onChange={(event) => setRawCode(event.target.value)}
|
onChange={(event) => setRawCode(event.target.value)}
|
||||||
placeholder="粘贴设备码或二维码链接"
|
placeholder="粘贴二维码链接,或者直接贴设备 UUID"
|
||||||
className="h-13 flex-1 rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
|
className="h-13 flex-1 rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -89,7 +115,7 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!content.trim()) {
|
if (!content.trim()) {
|
||||||
setNotice("请先写下您想说的话。\n");
|
setNotice("先写下想对老人说的话。\n");
|
||||||
setCreatedMessagePublicId(null);
|
setCreatedMessagePublicId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -118,7 +144,7 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
throw new Error(payload?.error || "留言发送失败,请稍后再试。");
|
throw new Error(payload?.error || "留言发送失败,请稍后再试。");
|
||||||
}
|
}
|
||||||
|
|
||||||
setNotice("留言已发出,长辈下次使用时就能看到。\n");
|
setNotice("留言已经写进家人的收件盒,老人下次打开或询问时就能看到。\n");
|
||||||
setCreatedMessagePublicId(
|
setCreatedMessagePublicId(
|
||||||
typeof payload?.publicId === "number" ? payload.publicId : null,
|
typeof payload?.publicId === "number" ? payload.publicId : null,
|
||||||
);
|
);
|
||||||
@ -142,10 +168,10 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
留言
|
给老人留句话
|
||||||
</p>
|
</p>
|
||||||
<h4 className="mt-2 font-[family-name:var(--font-display)] text-xl text-[var(--ink)]">
|
<h4 className="mt-2 font-[family-name:var(--font-display)] text-xl text-[var(--ink)]">
|
||||||
给长辈写一句问候
|
写一句今天就能被看到的问候
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
@ -170,7 +196,7 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
|
|
||||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="text-sm leading-7 text-[var(--muted)]">
|
<p className="text-sm leading-7 text-[var(--muted)]">
|
||||||
长辈和智能助理聊天时,就能看到这条留言。
|
这条话会进入老人的留言列表,也会被 function calling 工具读取到。
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Home, MessageCircle, Smartphone, Activity, ScanLine } from "lucide-react";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
{ href: "/", label: "首页", icon: Home },
|
{ href: "/", label: "总览" },
|
||||||
{ href: "/messages", label: "留言", icon: MessageCircle },
|
{ href: "/messages", label: "留言" },
|
||||||
{ href: "/devices", label: "设备", icon: Smartphone },
|
{ href: "/devices", label: "设备" },
|
||||||
{ href: "/activity", label: "动态", icon: Activity },
|
{ href: "/activity", label: "活动" },
|
||||||
{ href: "/scan", label: "扫码", icon: ScanLine },
|
{ href: "/scan", label: "扫码" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function isActivePath(currentPath: string, href: string) {
|
function isActivePath(currentPath: string, href: string) {
|
||||||
@ -22,7 +21,7 @@ type PanelShellProps = {
|
|||||||
currentPath: string;
|
currentPath: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
caregiverLabel: string;
|
caregiverToken: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
eyebrow?: string;
|
eyebrow?: string;
|
||||||
@ -32,10 +31,10 @@ export function PanelShell({
|
|||||||
currentPath,
|
currentPath,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
caregiverLabel,
|
caregiverToken,
|
||||||
children,
|
children,
|
||||||
actions,
|
actions,
|
||||||
eyebrow = "家人连线",
|
eyebrow = "Digital Human Bridge",
|
||||||
}: PanelShellProps) {
|
}: PanelShellProps) {
|
||||||
return (
|
return (
|
||||||
<main className="relative overflow-hidden px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
|
<main className="relative overflow-hidden px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
|
||||||
@ -43,26 +42,24 @@ export function PanelShell({
|
|||||||
<div className="mx-auto max-w-7xl space-y-6">
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
<header className="paper-panel relative overflow-hidden rounded-[36px] border border-white/70 px-6 py-7 shadow-[0_35px_90px_rgba(105,68,41,0.13)] sm:px-8 lg:px-10 lg:py-8">
|
<header className="paper-panel relative overflow-hidden rounded-[36px] border border-white/70 px-6 py-7 shadow-[0_35px_90px_rgba(105,68,41,0.13)] sm:px-8 lg:px-10 lg:py-8">
|
||||||
<div className="absolute -right-12 top-[-50px] h-44 w-44 rounded-full bg-[rgba(53,84,141,0.14)] blur-2xl" />
|
<div className="absolute -right-12 top-[-50px] h-44 w-44 rounded-full bg-[rgba(53,84,141,0.14)] blur-2xl" />
|
||||||
<div className="absolute left-10 top-10 h-24 w-24 rounded-full border border-[rgba(118,132,94,0.18)] z-0 pointer-events-none" />
|
<div className="absolute left-10 top-10 h-24 w-24 rounded-full border border-[rgba(118,132,94,0.18)]" />
|
||||||
|
|
||||||
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
|
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-3">
|
||||||
{navigationItems.map((item) => {
|
{navigationItems.map((item) => {
|
||||||
const active = isActivePath(currentPath, item.href);
|
const active = isActivePath(currentPath, item.href);
|
||||||
const Icon = item.icon;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={`inline-flex h-11 items-center justify-center gap-2 rounded-full px-4 text-sm font-semibold transition ${
|
className={`inline-flex h-11 items-center justify-center rounded-full px-4 text-sm font-semibold transition ${
|
||||||
active
|
active
|
||||||
? "bg-[var(--ink)] text-white shadow-[0_12px_30px_rgba(36,27,20,0.18)]"
|
? "bg-[var(--ink)] text-white shadow-[0_12px_30px_rgba(36,27,20,0.18)]"
|
||||||
: "border border-[var(--line)] bg-white/82 text-[var(--ink)] hover:bg-[var(--paper-soft)]"
|
: "border border-[var(--line)] bg-white/82 text-[var(--ink)] hover:bg-[var(--paper-soft)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon size={16} />
|
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
@ -81,16 +78,11 @@ export function PanelShell({
|
|||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
||||||
<span className="rounded-full border border-[var(--line)] bg-white/76 px-4 py-2">
|
<span className="rounded-full border border-[var(--line)] bg-white/76 px-4 py-2">
|
||||||
账号:{caregiverLabel}
|
当前家属账号:{caregiverToken.slice(0, 8).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-[var(--line)] bg-white/76 px-4 py-2">
|
||||||
|
不同功能已拆到独立路径,消息支持单条直达
|
||||||
</span>
|
</span>
|
||||||
<form action="/api/account/logout" method="post">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="inline-flex h-10 items-center justify-center rounded-full border border-[var(--line)] bg-white px-4 font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
退出登录
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -119,7 +119,7 @@ export function PwaControls() {
|
|||||||
|
|
||||||
async function enableNotifications() {
|
async function enableNotifications() {
|
||||||
if (!vapidPublicKey) {
|
if (!vapidPublicKey) {
|
||||||
setNotice("当前还没有配置推送服务,先将应用安装到桌面即可。\n");
|
setNotice("当前环境还没配置推送密钥,先把面板安装到桌面即可。\n");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,7 +160,7 @@ export function PwaControls() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setNotificationState("enabled");
|
setNotificationState("enabled");
|
||||||
setNotice("已开启消息提醒,长辈的新留言会直接推送到您的设备。\n");
|
setNotice("新留言会直接推到这台设备上,点开即可直达对应留言。\n");
|
||||||
} catch {
|
} catch {
|
||||||
setNotificationState("idle");
|
setNotificationState("idle");
|
||||||
setNotice("打开消息提醒失败,请稍后再试。\n");
|
setNotice("打开消息提醒失败,请稍后再试。\n");
|
||||||
@ -219,18 +219,18 @@ export function PwaControls() {
|
|||||||
return (
|
return (
|
||||||
<section className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_22px_55px_rgba(53,84,141,0.12)]">
|
<section className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_22px_55px_rgba(53,84,141,0.12)]">
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
快捷设置
|
安装与提醒
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
<h2 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
||||||
安装到桌面,随时收到长辈的消息
|
把家属面板装到桌面,并接收新留言提醒
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">
|
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">
|
||||||
安装后打开更快,开启提醒后,长辈每次留言都能立刻收到通知。
|
安装后打开速度会更像原生应用;开启提醒后,老人一旦通过桥接工具留话,点击通知就能直达那条留言。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
<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">
|
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
||||||
{isStandalone ? "已安装到桌面" : "当前在浏览器中打开"}
|
{isStandalone ? "当前已可从桌面直接打开" : "浏览器内访问中"}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
||||||
{notificationState === "enabled"
|
{notificationState === "enabled"
|
||||||
|
|||||||
@ -45,13 +45,38 @@ export function ScanClient() {
|
|||||||
|
|
||||||
scannerRef.current.stop();
|
scannerRef.current.stop();
|
||||||
setErrorText(null);
|
setErrorText(null);
|
||||||
setStatusText("识别到了设备,正在打开绑定页面……");
|
setStatusText("识别到了设备码,正在建立连接……");
|
||||||
|
|
||||||
startTransition(() => {
|
try {
|
||||||
router.replace(
|
const response = await fetch("/api/family/bind", {
|
||||||
`/bind?deviceUuid=${encodeURIComponent(scannedValue)}&source=scan`,
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ deviceUuid: scannedValue }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as
|
||||||
|
| { error?: string; deviceUuid?: string }
|
||||||
|
| null;
|
||||||
|
const deviceUuid = payload?.deviceUuid;
|
||||||
|
|
||||||
|
if (!response.ok || !deviceUuid) {
|
||||||
|
throw new Error(payload?.error || "绑定失败,请重新扫描一次。");
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace(
|
||||||
|
`/bind?deviceUuid=${encodeURIComponent(deviceUuid)}&source=scan`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setErrorText(
|
||||||
|
error instanceof Error ? error.message : "绑定失败,请重新扫描一次。",
|
||||||
);
|
);
|
||||||
});
|
setStatusText("请再把二维码对准镜头中央。");
|
||||||
|
await scannerRef.current.start().catch(() => null);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -66,7 +91,7 @@ export function ScanClient() {
|
|||||||
const hasCamera = await QrScanner.hasCamera();
|
const hasCamera = await QrScanner.hasCamera();
|
||||||
|
|
||||||
if (!hasCamera) {
|
if (!hasCamera) {
|
||||||
setErrorText("当前设备没有可用摄像头,请改用手动粘贴设备码。");
|
setErrorText("当前设备没有可用摄像头,请改用系统扫码或手动粘贴设备码。");
|
||||||
setStatusText("无法打开镜头");
|
setStatusText("无法打开镜头");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -89,7 +114,7 @@ export function ScanClient() {
|
|||||||
await scanner.start();
|
await scanner.start();
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setStatusText("请将长辈手机上的二维码放到取景框内。");
|
setStatusText("请把老人的二维码放到取景框中央。");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorText(
|
setErrorText(
|
||||||
@ -126,7 +151,7 @@ export function ScanClient() {
|
|||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
<p className="text-base font-semibold text-[var(--ink)]">{statusText}</p>
|
<p className="text-base font-semibold text-[var(--ink)]">{statusText}</p>
|
||||||
<p className="text-sm leading-7 text-[var(--muted)]">
|
<p className="text-sm leading-7 text-[var(--muted)]">
|
||||||
扫描成功后会自动连接长辈的设备,无需额外操作。
|
扫到后会直接把这位老人绑定到当前家属账号,不需要额外输密码。
|
||||||
</p>
|
</p>
|
||||||
{errorText ? (
|
{errorText ? (
|
||||||
<p className="rounded-[20px] bg-[var(--paper-soft)] px-4 py-3 text-sm leading-7 text-[var(--copper-deep)]">
|
<p className="rounded-[20px] bg-[var(--paper-soft)] px-4 py-3 text-sm leading-7 text-[var(--copper-deep)]">
|
||||||
|
|||||||
@ -121,8 +121,6 @@ export async function bindDeviceToCaregiver(
|
|||||||
|
|
||||||
const device = await ensureDeviceRegistration({ deviceUuid });
|
const device = await ensureDeviceRegistration({ deviceUuid });
|
||||||
|
|
||||||
await migrateLegacyCaregiverDataForDevice(caregiverId, device.id);
|
|
||||||
|
|
||||||
await prisma.deviceBinding.upsert({
|
await prisma.deviceBinding.upsert({
|
||||||
where: {
|
where: {
|
||||||
caregiverId_elderDeviceId: {
|
caregiverId_elderDeviceId: {
|
||||||
@ -140,102 +138,6 @@ export async function bindDeviceToCaregiver(
|
|||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function migrateLegacyCaregiverDataForDevice(
|
|
||||||
caregiverId: string,
|
|
||||||
elderDeviceId: string,
|
|
||||||
) {
|
|
||||||
const legacyCaregivers = await prisma.caregiverAccount.findMany({
|
|
||||||
where: {
|
|
||||||
id: { not: caregiverId },
|
|
||||||
username: null,
|
|
||||||
bindings: {
|
|
||||||
some: {
|
|
||||||
elderDeviceId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
take: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (legacyCaregivers.length !== 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacyCaregiverId = legacyCaregivers[0]?.id;
|
|
||||||
|
|
||||||
if (!legacyCaregiverId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
const legacyBindings = await tx.deviceBinding.findMany({
|
|
||||||
where: {
|
|
||||||
caregiverId: legacyCaregiverId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
elderDeviceId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
legacyBindings.map((binding) =>
|
|
||||||
tx.deviceBinding.upsert({
|
|
||||||
where: {
|
|
||||||
caregiverId_elderDeviceId: {
|
|
||||||
caregiverId,
|
|
||||||
elderDeviceId: binding.elderDeviceId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
caregiverId,
|
|
||||||
elderDeviceId: binding.elderDeviceId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tx.familyMessage.updateMany({
|
|
||||||
where: {
|
|
||||||
caregiverId: legacyCaregiverId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
caregiverId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.pushSubscription.updateMany({
|
|
||||||
where: {
|
|
||||||
caregiverId: legacyCaregiverId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
caregiverId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.deviceBinding.deleteMany({
|
|
||||||
where: {
|
|
||||||
caregiverId: legacyCaregiverId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.caregiverSession.deleteMany({
|
|
||||||
where: {
|
|
||||||
caregiverId: legacyCaregiverId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.caregiverAccount.delete({
|
|
||||||
where: {
|
|
||||||
id: legacyCaregiverId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseImportance(rawImportance?: string | null) {
|
function parseImportance(rawImportance?: string | null) {
|
||||||
const normalizedImportance = rawImportance?.trim().toUpperCase();
|
const normalizedImportance = rawImportance?.trim().toUpperCase();
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildCaregiverLoginPath,
|
buildCaregiverSessionBootstrapPath,
|
||||||
getCaregiverSession,
|
getCaregiverSession,
|
||||||
} from "@/lib/session";
|
} from "@/lib/session";
|
||||||
|
|
||||||
@ -9,7 +9,7 @@ export async function requireCaregiverSession(redirectTo: string) {
|
|||||||
const caregiver = await getCaregiverSession();
|
const caregiver = await getCaregiverSession();
|
||||||
|
|
||||||
if (!caregiver) {
|
if (!caregiver) {
|
||||||
redirect(buildCaregiverLoginPath(redirectTo));
|
redirect(buildCaregiverSessionBootstrapPath(redirectTo));
|
||||||
}
|
}
|
||||||
|
|
||||||
return caregiver;
|
return caregiver;
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export function getUsageLabel(eventType: UsageEventType | string) {
|
|||||||
case UsageEventType.APP_OPEN:
|
case UsageEventType.APP_OPEN:
|
||||||
return "打开了老人端应用";
|
return "打开了老人端应用";
|
||||||
case UsageEventType.SETTINGS_OPENED:
|
case UsageEventType.SETTINGS_OPENED:
|
||||||
return "查看了设置页面";
|
return "打开了家人连接页";
|
||||||
case UsageEventType.AI_SESSION_STARTED:
|
case UsageEventType.AI_SESSION_STARTED:
|
||||||
return "开始了一次陪伴对话";
|
return "开始了一次陪伴对话";
|
||||||
case UsageEventType.AI_SESSION_ENDED:
|
case UsageEventType.AI_SESSION_ENDED:
|
||||||
@ -49,7 +49,7 @@ export function getUsageLabel(eventType: UsageEventType | string) {
|
|||||||
case UsageEventType.CAMERA_DISABLED:
|
case UsageEventType.CAMERA_DISABLED:
|
||||||
return "关闭了镜头模式";
|
return "关闭了镜头模式";
|
||||||
case UsageEventType.TOOL_CALLED:
|
case UsageEventType.TOOL_CALLED:
|
||||||
return "使用了一项智能服务";
|
return "模型调用了一次桥接工具";
|
||||||
default:
|
default:
|
||||||
return eventType;
|
return eventType;
|
||||||
}
|
}
|
||||||
@ -60,7 +60,7 @@ export function getConversationRoleLabel(role: ConversationRole | string) {
|
|||||||
case ConversationRole.USER:
|
case ConversationRole.USER:
|
||||||
return "老人说";
|
return "老人说";
|
||||||
case ConversationRole.TOOL:
|
case ConversationRole.TOOL:
|
||||||
return "智能助理";
|
return "工具结果";
|
||||||
default:
|
default:
|
||||||
return "数字人回复";
|
return "数字人回复";
|
||||||
}
|
}
|
||||||
@ -110,5 +110,5 @@ export function getMessageHref(publicId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getDeviceName(displayName?: string | null) {
|
export function getDeviceName(displayName?: string | null) {
|
||||||
return displayName?.trim() || "长辈的设备";
|
return displayName?.trim() || "未命名陪伴设备";
|
||||||
}
|
}
|
||||||
171
lib/session.ts
171
lib/session.ts
@ -1,4 +1,3 @@
|
|||||||
import { compare, hash } from "bcryptjs";
|
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
@ -7,8 +6,6 @@ import { prisma } from "@/lib/prisma";
|
|||||||
export const CAREGIVER_SESSION_COOKIE = "dh-caregiver-session";
|
export const CAREGIVER_SESSION_COOKIE = "dh-caregiver-session";
|
||||||
|
|
||||||
const CAREGIVER_SESSION_MAX_AGE = 60 * 60 * 24 * 365;
|
const CAREGIVER_SESSION_MAX_AGE = 60 * 60 * 24 * 365;
|
||||||
const CAREGIVER_USERNAME_PATTERN = /^[a-z0-9][a-z0-9_-]{2,23}$/;
|
|
||||||
const CAREGIVER_PASSWORD_MIN_LENGTH = 8;
|
|
||||||
|
|
||||||
function createSessionToken() {
|
function createSessionToken() {
|
||||||
return randomUUID().replaceAll("-", "");
|
return randomUUID().replaceAll("-", "");
|
||||||
@ -32,170 +29,44 @@ function normalizeRedirectPath(redirectTo?: string) {
|
|||||||
return redirectTo;
|
return redirectTo;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCaregiverUsername(rawValue: string) {
|
async function upsertCaregiverByToken(sessionToken: string) {
|
||||||
return rawValue.trim().toLowerCase();
|
return prisma.caregiverAccount.upsert({
|
||||||
}
|
where: { sessionToken },
|
||||||
|
update: {},
|
||||||
function parseCaregiverUsername(rawValue: string) {
|
create: { sessionToken },
|
||||||
const username = normalizeCaregiverUsername(rawValue);
|
|
||||||
|
|
||||||
if (!CAREGIVER_USERNAME_PATTERN.test(username)) {
|
|
||||||
throw new Error("用户名请使用 3 到 24 位字母、数字、下划线或横线。");
|
|
||||||
}
|
|
||||||
|
|
||||||
return username;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCaregiverPassword(rawValue: string) {
|
|
||||||
if (rawValue.length < CAREGIVER_PASSWORD_MIN_LENGTH) {
|
|
||||||
throw new Error(`密码至少需要 ${CAREGIVER_PASSWORD_MIN_LENGTH} 位。`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rawValue.length > 72) {
|
|
||||||
throw new Error("密码请控制在 72 位以内。");
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCurrentSessionToken() {
|
|
||||||
return (await cookies()).get(CAREGIVER_SESSION_COOKIE)?.value || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createCaregiverSession(input: {
|
|
||||||
caregiverId: string;
|
|
||||||
userAgent?: string | null;
|
|
||||||
}) {
|
|
||||||
const currentSessionToken = await getCurrentSessionToken();
|
|
||||||
|
|
||||||
if (currentSessionToken) {
|
|
||||||
await prisma.caregiverSession.deleteMany({
|
|
||||||
where: {
|
|
||||||
sessionToken: currentSessionToken,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionToken = createSessionToken();
|
|
||||||
|
|
||||||
await prisma.caregiverSession.create({
|
|
||||||
data: {
|
|
||||||
caregiverId: input.caregiverId,
|
|
||||||
sessionToken,
|
|
||||||
userAgent: input.userAgent?.trim() || null,
|
|
||||||
lastSeenAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
(await cookies()).set(CAREGIVER_SESSION_COOKIE, sessionToken, getCookieOptions());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCaregiverLoginPath(redirectTo: string) {
|
export function buildCaregiverSessionBootstrapPath(redirectTo: string) {
|
||||||
const normalizedRedirect = normalizeRedirectPath(redirectTo);
|
const normalizedRedirect = normalizeRedirectPath(redirectTo);
|
||||||
return `/login?redirectTo=${encodeURIComponent(normalizedRedirect)}`;
|
return `/api/family/session?redirectTo=${encodeURIComponent(normalizedRedirect)}`;
|
||||||
}
|
|
||||||
|
|
||||||
export function getCaregiverDisplayName(input: {
|
|
||||||
username?: string | null;
|
|
||||||
nickname?: string | null;
|
|
||||||
}) {
|
|
||||||
return input.nickname?.trim() || input.username?.trim() || "临时账号";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeCaregiverRedirectPath(redirectTo?: string | null) {
|
|
||||||
return normalizeRedirectPath(redirectTo || undefined);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCaregiverSession() {
|
export async function getCaregiverSession() {
|
||||||
const sessionToken = await getCurrentSessionToken();
|
const sessionToken = (await cookies()).get(CAREGIVER_SESSION_COOKIE)?.value;
|
||||||
|
|
||||||
if (!sessionToken) {
|
if (!sessionToken) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await prisma.caregiverSession.findUnique({
|
return upsertCaregiverByToken(sessionToken);
|
||||||
where: { sessionToken },
|
|
||||||
include: {
|
|
||||||
caregiver: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return session.caregiver;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerCaregiverAccount(input: {
|
export async function getOrCreateCaregiverSession() {
|
||||||
username: string;
|
const cookieStore = await cookies();
|
||||||
password: string;
|
let sessionToken = cookieStore.get(CAREGIVER_SESSION_COOKIE)?.value;
|
||||||
userAgent?: string | null;
|
|
||||||
}) {
|
|
||||||
const username = parseCaregiverUsername(input.username);
|
|
||||||
const password = parseCaregiverPassword(input.password);
|
|
||||||
const existingCaregiver = await prisma.caregiverAccount.findUnique({
|
|
||||||
where: { username },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingCaregiver) {
|
if (!sessionToken) {
|
||||||
throw new Error("这个用户名已经被注册了,请直接登录。");
|
sessionToken = createSessionToken();
|
||||||
|
cookieStore.set(CAREGIVER_SESSION_COOKIE, sessionToken, getCookieOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
const caregiver = await prisma.caregiverAccount.create({
|
return upsertCaregiverByToken(sessionToken);
|
||||||
data: {
|
|
||||||
username,
|
|
||||||
passwordHash: await hash(password, 10),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await createCaregiverSession({
|
|
||||||
caregiverId: caregiver.id,
|
|
||||||
userAgent: input.userAgent,
|
|
||||||
});
|
|
||||||
|
|
||||||
return caregiver;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginCaregiverAccount(input: {
|
export function createCaregiverSessionCookieValue() {
|
||||||
username: string;
|
return {
|
||||||
password: string;
|
sessionToken: createSessionToken(),
|
||||||
userAgent?: string | null;
|
options: getCookieOptions(),
|
||||||
}) {
|
};
|
||||||
const username = parseCaregiverUsername(input.username);
|
|
||||||
const password = parseCaregiverPassword(input.password);
|
|
||||||
const caregiver = await prisma.caregiverAccount.findUnique({
|
|
||||||
where: { username },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!caregiver?.passwordHash) {
|
|
||||||
throw new Error("用户名或密码不正确。");
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordMatched = await compare(password, caregiver.passwordHash);
|
|
||||||
|
|
||||||
if (!passwordMatched) {
|
|
||||||
throw new Error("用户名或密码不正确。");
|
|
||||||
}
|
|
||||||
|
|
||||||
await createCaregiverSession({
|
|
||||||
caregiverId: caregiver.id,
|
|
||||||
userAgent: input.userAgent,
|
|
||||||
});
|
|
||||||
|
|
||||||
return caregiver;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearCurrentCaregiverSession() {
|
|
||||||
const sessionToken = await getCurrentSessionToken();
|
|
||||||
|
|
||||||
if (sessionToken) {
|
|
||||||
await prisma.caregiverSession.deleteMany({
|
|
||||||
where: {
|
|
||||||
sessionToken,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
(await cookies()).delete(CAREGIVER_SESSION_COOKIE);
|
|
||||||
}
|
}
|
||||||
6308
package-lock.json
generated
6308
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,15 +12,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.7.0",
|
"@prisma/adapter-pg": "^7.7.0",
|
||||||
"@prisma/client": "^7.7.0",
|
"@prisma/client": "^7.7.0",
|
||||||
"bcryptjs": "^3.0.3",
|
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"lucide-react": "^1.8.0",
|
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"recharts": "^3.8.1",
|
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"web-push": "^3.6.7"
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- A unique constraint covering the columns `[username]` on the table `CaregiverAccount` will be added. If there are existing duplicate values, this will fail.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "CaregiverAccount" ADD COLUMN "passwordHash" TEXT,
|
|
||||||
ADD COLUMN "username" TEXT;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "CaregiverSession" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"caregiverId" TEXT NOT NULL,
|
|
||||||
"sessionToken" TEXT NOT NULL,
|
|
||||||
"userAgent" TEXT,
|
|
||||||
"lastSeenAt" TIMESTAMP(3),
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "CaregiverSession_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "CaregiverSession_sessionToken_key" ON "CaregiverSession"("sessionToken");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "CaregiverSession_caregiverId_updatedAt_idx" ON "CaregiverSession"("caregiverId", "updatedAt" DESC);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "CaregiverAccount_username_key" ON "CaregiverAccount"("username");
|
|
||||||
|
|
||||||
-- MigrateData
|
|
||||||
INSERT INTO "CaregiverSession" (
|
|
||||||
"id",
|
|
||||||
"caregiverId",
|
|
||||||
"sessionToken",
|
|
||||||
"lastSeenAt",
|
|
||||||
"createdAt",
|
|
||||||
"updatedAt"
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
CONCAT('legacy_', md5("id" || ':' || "sessionToken")),
|
|
||||||
"id",
|
|
||||||
"sessionToken",
|
|
||||||
NOW(),
|
|
||||||
NOW(),
|
|
||||||
NOW()
|
|
||||||
FROM "CaregiverAccount"
|
|
||||||
WHERE "sessionToken" IS NOT NULL;
|
|
||||||
|
|
||||||
-- DropIndex
|
|
||||||
DROP INDEX "CaregiverAccount_sessionToken_key";
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "CaregiverAccount" DROP COLUMN "sessionToken";
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "CaregiverSession" ADD CONSTRAINT "CaregiverSession_caregiverId_fkey" FOREIGN KEY ("caregiverId") REFERENCES "CaregiverAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -46,31 +46,16 @@ enum ToolCallStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model CaregiverAccount {
|
model CaregiverAccount {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
username String? @unique
|
sessionToken String @unique
|
||||||
passwordHash String?
|
nickname String?
|
||||||
nickname String?
|
createdAt DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
updatedAt DateTime @updatedAt
|
||||||
updatedAt DateTime @updatedAt
|
bindings DeviceBinding[]
|
||||||
sessions CaregiverSession[]
|
messages FamilyMessage[]
|
||||||
bindings DeviceBinding[]
|
|
||||||
messages FamilyMessage[]
|
|
||||||
pushSubscriptions PushSubscription[]
|
pushSubscriptions PushSubscription[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model CaregiverSession {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
caregiverId String
|
|
||||||
sessionToken String @unique
|
|
||||||
userAgent String?
|
|
||||||
lastSeenAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([caregiverId, updatedAt(sort: Desc)])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ElderDevice {
|
model ElderDevice {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
deviceUuid String @unique
|
deviceUuid String @unique
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user