全新UI语言
This commit is contained in:
parent
4dde861c99
commit
386a28709f
50
DESIGN_SYSTEM.md
Normal file
50
DESIGN_SYSTEM.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# 安智伴家属端设计规范 v2
|
||||||
|
|
||||||
|
本规范以移动版原型为唯一视觉基准,设计目标是「安静、可信、清晰」:减少装饰,优先呈现长辈状态、留言与陪伴动态。
|
||||||
|
|
||||||
|
## 画布与布局
|
||||||
|
|
||||||
|
- 移动端内容宽度:`100%`,最大 `430px`;原型验收尺寸为 `390 × 844`。
|
||||||
|
- 页面背景:`#F4F3F1`;桌面居中画布:`#E9E7E3`。
|
||||||
|
- 页面水平留白:`14px`;模块垂直间距:`12px`,首页可用 `14px`。
|
||||||
|
- 主页面预留 `84px` 底部导航空间;详情页使用顶部返回栏,不显示底部导航。
|
||||||
|
|
||||||
|
## 颜色
|
||||||
|
|
||||||
|
| 语义 | 值 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 主文字 | `#201D1A` | 标题、正文、深色按钮 |
|
||||||
|
| 次文字 | `#3D3833` | 列表正文 |
|
||||||
|
| 辅助文字 | `#8A857E` | 时间、状态、说明 |
|
||||||
|
| 弱辅助文字 | `#B3AEA6` | 次级时间、图标 |
|
||||||
|
| 品牌强调 | `#C05A1E` | 主按钮、未读、当前导航 |
|
||||||
|
| 成功/在线 | `#3F9D63` | 在线状态、成功反馈 |
|
||||||
|
| 危险操作 | `#B4482C` | 退出、解绑 |
|
||||||
|
| 分隔线 | `rgba(32,29,26,.08)` | 卡片与列表分隔 |
|
||||||
|
|
||||||
|
所有值均定义在 `app/globals.css` 的 CSS 变量中,页面不得自行引入新的近似色。
|
||||||
|
|
||||||
|
## 字体与信息层级
|
||||||
|
|
||||||
|
- 字体栈:系统无衬线字体,中文优先苹方、冬青黑体、微软雅黑。
|
||||||
|
- 页面标题:`20px / 700`;详情标题:`16px / 700`。
|
||||||
|
- 区块标题:`14px / 700`;列表正文:`14px / 1.5`。
|
||||||
|
- 元信息:`11–12px`;数字指标:`18–20px / 700`。
|
||||||
|
- 不使用全大写眉题、衬线展示字体或大面积超大标题。
|
||||||
|
|
||||||
|
## 组件规则
|
||||||
|
|
||||||
|
- 卡片:白色背景、`12px` 圆角、`1px` 弱边框,不使用重阴影。
|
||||||
|
- 列表:左右 `14px`、上下 `11–13px`;列表内部使用弱分隔线。
|
||||||
|
- 按钮:高度 `40px`,圆角 `10px`;小按钮高度 `32px`、圆角 `8px`。
|
||||||
|
- 输入框:高度 `44px`,圆角 `10px`;聚焦时使用品牌色边框与轻微外环。
|
||||||
|
- 标签:仅用于「重要」等高价值状态,字号 `10px`,浅品牌色背景。
|
||||||
|
- 底部导航:固定四项——概览、留言、设备、我的;当前项使用品牌色。
|
||||||
|
- 分段控件:浅灰底,选中项为白底并带极轻阴影。
|
||||||
|
|
||||||
|
## 交互与可访问性
|
||||||
|
|
||||||
|
- 页面跳转使用 Next.js `Link`,保留客户端导航与预取能力。
|
||||||
|
- 可点击区域不小于 `32px`;主要操作不小于 `40px`。
|
||||||
|
- 状态不能只依赖颜色,在线、未读、重要均同时提供文字或数量。
|
||||||
|
- 动效只用于导航进度、扫码线和必要反馈;尊重系统减少动态设置。
|
||||||
@ -1,181 +1,41 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
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, getConversationRoleLabel, getDeviceName, getUsageLabel, truncateText } from "@/lib/panel-format";
|
||||||
formatDateTime,
|
|
||||||
getConversationRoleLabel,
|
|
||||||
getDeviceName,
|
|
||||||
getUsageLabel,
|
|
||||||
truncateText,
|
|
||||||
} from "@/lib/panel-format";
|
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
import { getCaregiverDisplayName } from "@/lib/session";
|
import { getCaregiverDisplayName } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
function aggregateByDate(items: { createdAt: Date | string }[]) {
|
export default async function ActivityPage({ searchParams }: PageProps<"/activity">) {
|
||||||
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() {
|
|
||||||
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 rawTab = (await searchParams).tab;
|
||||||
);
|
const tab = rawTab === "chat" || rawTab === "tool" ? rawTab : "usage";
|
||||||
|
|
||||||
const usageChartData = aggregateByDate(usageEvents);
|
const rows = tab === "usage"
|
||||||
const conversationChartData = aggregateByDate(conversationTurns);
|
? usageEvents.map((item) => ({ id: item.id, top: getDeviceName(item.elderDevice.displayName), time: formatDateTime(item.createdAt), body: getUsageLabel(item.eventType) }))
|
||||||
|
: tab === "chat"
|
||||||
|
? conversationTurns.map((item) => ({ id: item.id, top: getConversationRoleLabel(item.role), time: formatDateTime(item.createdAt), body: truncateText(item.content, 180) }))
|
||||||
|
: toolCalls.map((item) => ({ id: item.id, top: item.toolName, time: formatDateTime(item.createdAt), body: truncateText(item.outputText || item.argumentsJson, 180) }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell currentPath="/activity" title="陪伴动态" caregiverLabel={getCaregiverDisplayName(caregiver)} backHref="/">
|
||||||
currentPath="/activity"
|
<div className="segment">
|
||||||
title="陪伴动态"
|
<Link href="/activity?tab=usage" className={tab === "usage" ? "active" : ""}>使用</Link>
|
||||||
description="了解长辈最近的使用情况、聊天内容和智能服务记录。"
|
<Link href="/activity?tab=chat" className={tab === "chat" ? "active" : ""}>对话</Link>
|
||||||
caregiverLabel={getCaregiverDisplayName(caregiver)}
|
<Link href="/activity?tab=tool" className={tab === "tool" ? "active" : ""}>智能服务</Link>
|
||||||
>
|
</div>
|
||||||
{usageEvents.length === 0 && conversationTurns.length === 0 && toolCalls.length === 0 ? (
|
<section className="card">
|
||||||
<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)]">
|
{rows.length ? rows.map((row) => (
|
||||||
<h2 className="font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<article key={row.id} className="timeline-item">
|
||||||
暂时没有动态
|
<div className="timeline-top" style={{ color: tab === "tool" ? "var(--accent)" : undefined }}>{row.top}<span className="timeline-time">{row.time}</span></div>
|
||||||
</h2>
|
<p className="timeline-body">{row.body}</p>
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
</article>
|
||||||
长辈开始使用智能助理后,这里会自动记录使用情况和聊天内容,方便您随时了解。
|
)) : <div className="empty">这个分类暂时还没有动态。</div>}
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
) : (
|
<p className="muted-note">「智能服务」记录数字人助理自主调用的工具,包括照护知识查询与双向留言等。</p>
|
||||||
<>
|
|
||||||
{(usageChartData.length > 1 || conversationChartData.length > 1) && (
|
|
||||||
<section className="grid gap-6 xl:grid-cols-2">
|
|
||||||
{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 className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
使用频率
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
|
||||||
最近使用趋势
|
|
||||||
</h2>
|
|
||||||
<div className="mt-4">
|
|
||||||
<ActivityChart data={usageChartData} color="#c76733" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
|
||||||
最近对话趋势
|
|
||||||
</h2>
|
|
||||||
<div className="mt-4">
|
|
||||||
<ActivityChart data={conversationChartData} color="#76845e" />
|
|
||||||
</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 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.length === 0 ? (
|
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
暂时没有聊天记录。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
conversationTurns.map((turn) => (
|
|
||||||
<div key={turn.id} className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
|
|
||||||
<span>{getConversationRoleLabel(turn.role)}</span>
|
|
||||||
<span>{formatDateTime(turn.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-[var(--muted)]">
|
|
||||||
{getDeviceName(turn.elderDevice.displayName)}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(turn.content, 132)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
智能服务
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
助理做了什么
|
|
||||||
</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>
|
|
||||||
) : (
|
|
||||||
toolCalls.map((log) => (
|
|
||||||
<div key={log.id} className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
|
|
||||||
<span>{getDeviceName(log.elderDevice.displayName)}</span>
|
|
||||||
<span>{formatDateTime(log.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm font-semibold text-[var(--ink)]">
|
|
||||||
{log.toolName}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
{truncateText(log.outputText || log.argumentsJson, 136)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -4,224 +4,52 @@ import { notFound } from "next/navigation";
|
|||||||
import { ManageDeviceForm, SendMessageForm } from "@/components/dashboard-actions";
|
import { ManageDeviceForm, SendMessageForm } from "@/components/dashboard-actions";
|
||||||
import { PanelShell } from "@/components/panel-shell";
|
import { PanelShell } from "@/components/panel-shell";
|
||||||
import { getCaregiverDeviceDetail } from "@/lib/caregiver-panel";
|
import { getCaregiverDeviceDetail } from "@/lib/caregiver-panel";
|
||||||
import {
|
import { formatDateTime, getConversationRoleLabel, getDeviceName, getMessageHref, getUsageLabel, truncateText } from "@/lib/panel-format";
|
||||||
formatDateTime,
|
|
||||||
getConversationRoleLabel,
|
|
||||||
getDeviceName,
|
|
||||||
getImportanceLabel,
|
|
||||||
getMessageHref,
|
|
||||||
getUsageLabel,
|
|
||||||
truncateText,
|
|
||||||
} from "@/lib/panel-format";
|
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
import { getCaregiverDisplayName } from "@/lib/session";
|
import { getCaregiverDisplayName } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type DeviceDetailPageProps = {
|
export default async function DeviceDetailPage({ params, searchParams }: PageProps<"/devices/[deviceUuid]">) {
|
||||||
params: Promise<{ deviceUuid: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function DeviceDetailPage({ params }: DeviceDetailPageProps) {
|
|
||||||
const { deviceUuid } = await params;
|
const { deviceUuid } = await params;
|
||||||
const caregiver = await requireCaregiverSession(`/devices/${deviceUuid}`);
|
const caregiver = await requireCaregiverSession(`/devices/${deviceUuid}`);
|
||||||
const binding = await getCaregiverDeviceDetail(caregiver.id, deviceUuid);
|
const binding = await getCaregiverDeviceDetail(caregiver.id, deviceUuid);
|
||||||
|
if (!binding) notFound();
|
||||||
if (!binding) {
|
const rawTab = (await searchParams).tab;
|
||||||
notFound();
|
const tab = rawTab === "chat" || rawTab === "usage" ? rawTab : "msg";
|
||||||
}
|
const messages = binding.elderDevice.messages;
|
||||||
|
|
||||||
const elderMessages = binding.elderDevice.messages.filter(
|
|
||||||
(message) => message.direction === "ELDER_TO_FAMILY",
|
|
||||||
);
|
|
||||||
const familyMessages = binding.elderDevice.messages.filter(
|
|
||||||
(message) => message.direction === "FAMILY_TO_ELDER",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell currentPath="/devices" title={getDeviceName(binding.alias, binding.elderDevice.displayName)} caregiverLabel={getCaregiverDisplayName(caregiver)} backHref="/devices"
|
||||||
currentPath="/devices"
|
actions={<span className="row-meta" style={{ margin: 0 }}><span className="dot online" style={{ margin: 0 }} />{formatDateTime(binding.elderDevice.lastSeenAt)}</span>}>
|
||||||
title={getDeviceName(binding.elderDevice.displayName)}
|
<section className="stats" style={{ gridTemplateColumns: "repeat(3,1fr)" }}>
|
||||||
description="给长辈写问候、查看留言和最近的使用动态。"
|
<div className="stat accent"><strong>{binding.elderUnreadCount}</strong><span>新留言</span></div>
|
||||||
caregiverLabel={getCaregiverDisplayName(caregiver)}
|
<div className="stat"><strong>{binding.familyUnreadCount}</strong><span>待长辈听</span></div>
|
||||||
>
|
<div className="stat"><strong>{binding.elderDevice._count.conversationTurns}</strong><span>陪伴对话</span></div>
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">新留言</p>
|
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{binding.elderUnreadCount}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">待查看</p>
|
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{binding.familyUnreadCount}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">最近在线</p>
|
|
||||||
<p className="mt-3 text-lg font-semibold text-[var(--ink)]">
|
|
||||||
{formatDateTime(binding.elderDevice.lastSeenAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">设备链接</p>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(binding.bindUrl, 52)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<ManageDeviceForm
|
|
||||||
deviceUuid={binding.elderDevice.deviceUuid}
|
|
||||||
initialAlias={binding.alias}
|
|
||||||
deviceLabel={getDeviceName(binding.elderDevice.displayName)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SendMessageForm deviceUuid={binding.elderDevice.deviceUuid} />
|
<SendMessageForm deviceUuid={binding.elderDevice.deviceUuid} />
|
||||||
|
|
||||||
<section className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
<div className="segment">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<Link href={`/devices/${deviceUuid}?tab=msg`} className={tab === "msg" ? "active" : ""}>留言</Link>
|
||||||
<div>
|
<Link href={`/devices/${deviceUuid}?tab=chat`} className={tab === "chat" ? "active" : ""}>对话</Link>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<Link href={`/devices/${deviceUuid}?tab=usage`} className={tab === "usage" ? "active" : ""}>使用</Link>
|
||||||
长辈的话
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
最近收到的留言
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<Link
|
|
||||||
href="/messages"
|
<section className="card">
|
||||||
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)]"
|
{tab === "msg" ? (messages.length ? messages.map((message) => (
|
||||||
>
|
<Link key={message.id} href={getMessageHref(message.publicId)} className="timeline-item" style={{ display: "block" }}>
|
||||||
全部留言
|
<div className="timeline-top" style={{ color: message.direction === "FAMILY_TO_ELDER" ? "var(--accent)" : undefined }}>{message.direction === "FAMILY_TO_ELDER" ? "我发出的问候" : "长辈捎来的话"}<span className="timeline-time">{formatDateTime(message.createdAt)}</span></div>
|
||||||
|
<p className="timeline-body">{truncateText(message.content, 126)}</p>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
)) : <div className="empty">还没有留言。</div>) : tab === "chat" ? (binding.elderDevice.conversationTurns.length ? binding.elderDevice.conversationTurns.map((turn) => (
|
||||||
|
<article key={turn.id} className="timeline-item"><div className="timeline-top">{getConversationRoleLabel(turn.role)}<span className="timeline-time">{formatDateTime(turn.createdAt)}</span></div><p className="timeline-body">{truncateText(turn.content, 150)}</p></article>
|
||||||
<div className="mt-5 space-y-3">
|
)) : <div className="empty">还没有聊天记录。</div>) : (binding.elderDevice.usageEvents.length ? binding.elderDevice.usageEvents.map((event) => (
|
||||||
{elderMessages.length === 0 ? (
|
<article key={event.id} className="timeline-item"><div className="timeline-top">使用记录<span className="timeline-time">{formatDateTime(event.createdAt)}</span></div><p className="timeline-body">{getUsageLabel(event.eventType)}</p></article>
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
)) : <div className="empty">暂时还没有使用记录。</div>)}
|
||||||
还没有收到长辈的留言。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
elderMessages.map((message) => (
|
|
||||||
<Link
|
|
||||||
key={message.id}
|
|
||||||
href={getMessageHref(message.publicId)}
|
|
||||||
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
|
|
||||||
<span className="rounded-full bg-white px-3 py-1 font-semibold text-[var(--ink)]">
|
|
||||||
#{message.publicId}
|
|
||||||
</span>
|
|
||||||
<span>{getImportanceLabel(message.importance)}</span>
|
|
||||||
<span>{formatDateTime(message.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(message.content, 112)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
<details className="device-manage"><summary className="btn block">设备设置</summary><ManageDeviceForm deviceUuid={binding.elderDevice.deviceUuid} initialAlias={binding.alias} deviceLabel={getDeviceName(binding.elderDevice.displayName)} /></details>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
最近活动
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
最近的使用情况
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{binding.elderDevice.usageEvents.length === 0 ? (
|
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
暂时还没有使用记录。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
binding.elderDevice.usageEvents.map((event) => (
|
|
||||||
<div key={event.id} className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
|
|
||||||
<span>{getUsageLabel(event.eventType)}</span>
|
|
||||||
<span>{formatDateTime(event.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<section className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
我的问候
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
已发出的留言
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{familyMessages.length === 0 ? (
|
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
还没有给这位长辈写过留言。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
familyMessages.map((message) => (
|
|
||||||
<Link
|
|
||||||
key={message.id}
|
|
||||||
href={getMessageHref(message.publicId)}
|
|
||||||
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
|
|
||||||
<span className="rounded-full bg-white px-3 py-1 font-semibold text-[var(--ink)]">
|
|
||||||
#{message.publicId}
|
|
||||||
</span>
|
|
||||||
<span>{getImportanceLabel(message.importance)}</span>
|
|
||||||
<span>{formatDateTime(message.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(message.content, 112)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
最近对话片段
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
最近的聊天记录
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{binding.elderDevice.conversationTurns.length === 0 ? (
|
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
还没有聊天记录。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
binding.elderDevice.conversationTurns.map((turn) => (
|
|
||||||
<div key={turn.id} className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
|
|
||||||
<span>{getConversationRoleLabel(turn.role)}</span>
|
|
||||||
<span>{formatDateTime(turn.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(turn.content, 124)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,9 +1,10 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ChevronRight, Smartphone } from "lucide-react";
|
||||||
|
|
||||||
import { BindDeviceForm } from "@/components/dashboard-actions";
|
import { BindDeviceForm } from "@/components/dashboard-actions";
|
||||||
import { PanelShell } from "@/components/panel-shell";
|
import { PanelShell } from "@/components/panel-shell";
|
||||||
import { getCaregiverDevices } from "@/lib/caregiver-panel";
|
import { getCaregiverDevices } from "@/lib/caregiver-panel";
|
||||||
import { formatDateTime, getDeviceName, truncateText } from "@/lib/panel-format";
|
import { formatDateTime, getDeviceName } from "@/lib/panel-format";
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
import { getCaregiverDisplayName } from "@/lib/session";
|
import { getCaregiverDisplayName } from "@/lib/session";
|
||||||
|
|
||||||
@ -12,134 +13,40 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function DevicesPage() {
|
export default async function DevicesPage() {
|
||||||
const caregiver = await requireCaregiverSession("/devices");
|
const caregiver = await requireCaregiverSession("/devices");
|
||||||
const devices = await getCaregiverDevices(caregiver.id);
|
const devices = await getCaregiverDevices(caregiver.id);
|
||||||
|
const unread = devices.reduce((n, d) => n + d.elderUnreadCount, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell currentPath="/devices" title="我的设备" caregiverLabel={getCaregiverDisplayName(caregiver)} unreadCount={unread}
|
||||||
currentPath="/devices"
|
actions={<Link href="/scan" className="btn small">扫码绑定</Link>}>
|
||||||
title="我的设备"
|
<section className="card">
|
||||||
description="管理已连接的长辈设备,点击进入设备详情查看留言和使用动态。"
|
{devices.length ? devices.map((binding) => (
|
||||||
caregiverLabel={getCaregiverDisplayName(caregiver)}
|
<Link key={binding.id} href={`/devices/${binding.elderDevice.deviceUuid}`} className="list-row" style={{ alignItems: "center", gap: 12 }}>
|
||||||
>
|
<span className="device-icon"><Smartphone size={19} strokeWidth={1.8} /></span>
|
||||||
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
<span className="row-main">
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span className="row-title">{getDeviceName(binding.alias, binding.elderDevice.displayName)}</span>
|
||||||
|
<span className={`dot ${binding.elderDevice.lastSeenAt ? "online" : "offline"}`} style={{ marginTop: 0 }} />
|
||||||
|
</span>
|
||||||
|
<span className="row-meta">{formatDateTime(binding.elderDevice.lastSeenAt)} · 留言 {binding.elderDevice._count.messages} · 对话 {binding.elderDevice._count.conversationTurns} · 服务 {binding.elderDevice._count.toolCalls}</span>
|
||||||
|
</span>
|
||||||
|
{binding.elderUnreadCount > 0 ? <span className="nav-badge" style={{ position: "static" }}>{binding.elderUnreadCount}</span> : null}
|
||||||
|
<ChevronRight size={14} color="var(--muted-light)" />
|
||||||
|
</Link>
|
||||||
|
)) : <div className="empty">还没有连接设备,扫描长辈屏幕上的二维码即可添加。</div>}
|
||||||
|
</section>
|
||||||
|
|
||||||
<BindDeviceForm />
|
<BindDeviceForm />
|
||||||
|
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
<details className="card">
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<summary className="list-row" style={{ alignItems: "center", fontSize: 13, fontWeight: 600, listStyle: "none" }}>
|
||||||
使用说明
|
<span style={{ flex: 1 }}>如何连接长辈的设备?</span><span className="row-meta" style={{ margin: 0 }}>展开</span>
|
||||||
</p>
|
</summary>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<div className="card-pad" style={{ borderTop: "1px solid var(--line)", color: "var(--muted)", fontSize: 12.5, lineHeight: 1.7 }}>
|
||||||
如何连接长辈的设备?
|
<p>1. 让长辈打开陪伴屏上的「家人连接」页面。</p>
|
||||||
</h2>
|
<p>2. 扫描二维码,或把设备码粘贴到上方输入框。</p>
|
||||||
<div className="mt-4 space-y-3 rounded-[24px] bg-[var(--paper-soft)] p-5 text-sm leading-7 text-[var(--muted)]">
|
<p>3. 连接后即可收到长辈的留言、对话与使用动态。</p>
|
||||||
<p>1. 让长辈打开手机上的「家人连接」页面,屏幕会显示一个二维码。</p>
|
|
||||||
<p>2. 您用自己的手机扫描这个二维码,或将设备码粘贴到左侧输入框。</p>
|
|
||||||
<p>3. 连接成功后,即可在这里查看长辈的留言、对话和使用动态。</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
|
||||||
<Link
|
|
||||||
href="/scan"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--olive)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--olive-deep)]"
|
|
||||||
>
|
|
||||||
扫码连接
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/messages"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
查看留言
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{devices.length === 0 ? (
|
|
||||||
<section className="rounded-[32px] border border-dashed border-[var(--line)] bg-white/72 px-6 py-10 text-center shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
|
||||||
<h2 className="font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
还没有连接设备
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
|
||||||
扫描长辈手机上的二维码,或者在上方粘贴设备码,即可开始接收长辈的留言和动态。
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<section className="grid gap-6 lg:grid-cols-2">
|
|
||||||
{devices.map((binding) => {
|
|
||||||
const latestMessage = binding.elderDevice.messages[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
key={binding.id}
|
|
||||||
className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]"
|
|
||||||
>
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
已连接
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
{getDeviceName(binding.elderDevice.displayName)}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
|
||||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
|
||||||
最近在线:{formatDateTime(binding.elderDevice.lastSeenAt)}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
|
||||||
新留言 {binding.elderUnreadCount}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
|
||||||
待查看 {binding.familyUnreadCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
|
||||||
<p className="text-xs text-[var(--muted)]">留言</p>
|
|
||||||
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
|
||||||
{binding.elderDevice._count.messages}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
|
||||||
<p className="text-xs text-[var(--muted)]">对话</p>
|
|
||||||
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
|
||||||
{binding.elderDevice._count.conversationTurns}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
|
||||||
<p className="text-xs text-[var(--muted)]">智能服务</p>
|
|
||||||
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
|
||||||
{binding.elderDevice._count.toolCalls}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<p className="text-sm font-semibold text-[var(--ink)]">最新动态</p>
|
|
||||||
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
{latestMessage
|
|
||||||
? truncateText(latestMessage.content, 96)
|
|
||||||
: "还没有收到新的留言或问候。"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
|
||||||
<Link
|
|
||||||
href={`/devices/${binding.elderDevice.deviceUuid}`}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
|
||||||
>
|
|
||||||
查看详情
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/messages"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
查看留言
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
207
app/globals.css
207
app/globals.css
@ -1,103 +1,132 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--paper: #f4ece0;
|
--canvas: #e9e7e3;
|
||||||
--paper-soft: #fbf7f1;
|
--paper: #f4f3f1;
|
||||||
--paper-strong: #fffdf8;
|
--surface: #ffffff;
|
||||||
--ink: #241b14;
|
--surface-soft: #faf9f8;
|
||||||
--ink-soft: #3d2d20;
|
--ink: #201d1a;
|
||||||
--muted: #726153;
|
--ink-soft: #3d3833;
|
||||||
--line: rgba(85, 60, 42, 0.15);
|
--muted: #8a857e;
|
||||||
--copper: #c76733;
|
--muted-light: #b3aea6;
|
||||||
--copper-deep: #964722;
|
--line: rgba(32, 29, 26, 0.08);
|
||||||
--olive: #76845e;
|
--line-strong: rgba(32, 29, 26, 0.14);
|
||||||
--olive-deep: #596946;
|
--accent: #c05a1e;
|
||||||
--font-body:
|
--accent-hover: #96431a;
|
||||||
system-ui,
|
--accent-soft: rgba(192, 90, 30, 0.1);
|
||||||
-apple-system,
|
--copper: var(--accent);
|
||||||
BlinkMacSystemFont,
|
--copper-deep: var(--accent-hover);
|
||||||
"Segoe UI",
|
--paper-soft: var(--surface-soft);
|
||||||
"PingFang SC",
|
--olive: #65765a;
|
||||||
"Hiragino Sans GB",
|
--olive-deep: #4f6046;
|
||||||
"Microsoft YaHei UI",
|
--success: #3f9d63;
|
||||||
"Microsoft YaHei",
|
--danger: #b4482c;
|
||||||
sans-serif;
|
--radius-sm: 8px;
|
||||||
--font-display:
|
--radius-md: 10px;
|
||||||
ui-serif,
|
--radius-lg: 12px;
|
||||||
"Songti SC",
|
--font-body: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
"STSong",
|
--font-display: var(--font-body);
|
||||||
"SimSun",
|
|
||||||
"Times New Roman",
|
|
||||||
serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--paper);
|
--color-background: var(--paper);
|
||||||
--color-foreground: var(--ink);
|
--color-foreground: var(--ink);
|
||||||
--font-sans: var(--font-body);
|
--font-sans: var(--font-body);
|
||||||
--font-serif: var(--font-display);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* { box-sizing: border-box; }
|
||||||
box-sizing: border-box;
|
html { background: var(--canvas); }
|
||||||
|
body { margin: 0; color: var(--ink); background: var(--canvas); font-family: var(--font-body); }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
button, input, textarea, select { font: inherit; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
textarea { resize: none; }
|
||||||
|
*::-webkit-scrollbar { display: none; }
|
||||||
|
::selection { background: rgba(192, 90, 30, 0.18); }
|
||||||
|
|
||||||
|
.app-frame {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 430px;
|
||||||
|
min-height: 100dvh;
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--paper);
|
||||||
|
box-shadow: 0 0 0 1px rgba(32,29,26,.03);
|
||||||
}
|
}
|
||||||
|
.app-content { min-height: 100dvh; padding: 14px 14px 84px; }
|
||||||
|
.app-content.detail { padding-top: 0; padding-bottom: 24px; }
|
||||||
|
.app-stack { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.page-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:2px 2px 0; }
|
||||||
|
.page-heading h1 { margin:0; font-size:20px; line-height:1.35; font-weight:700; letter-spacing:.01em; }
|
||||||
|
.page-heading p { margin:3px 0 0; color:var(--muted); font-size:12px; line-height:1.5; }
|
||||||
|
.avatar { display:flex; width:34px; height:34px; flex:none; align-items:center; justify-content:center; border:0; border-radius:50%; background:var(--ink); color:var(--paper); font-size:14px; font-weight:600; }
|
||||||
|
.detail-header { position:sticky; top:0; z-index:5; display:flex; min-height:54px; align-items:center; gap:6px; padding:10px 0; background:rgba(244,243,241,.96); backdrop-filter:blur(10px); }
|
||||||
|
.detail-header h1 { margin:0; flex:1; font-size:16px; font-weight:700; }
|
||||||
|
.back-button { display:flex; width:30px; height:30px; align-items:center; justify-content:center; border:0; background:transparent; color:var(--ink); }
|
||||||
|
.card { overflow:hidden; border:1px solid var(--line); border-radius:var(--radius-lg); background:var(--surface); }
|
||||||
|
.card-pad { padding:13px 14px; }
|
||||||
|
.list-row { display:flex; width:100%; align-items:flex-start; gap:10px; padding:11px 14px; border:0; border-top:1px solid var(--line); background:transparent; color:var(--ink); text-align:left; }
|
||||||
|
.list-row:first-child { border-top:0; }
|
||||||
|
.list-row:hover { background:var(--surface-soft); }
|
||||||
|
.row-main { flex:1; min-width:0; }
|
||||||
|
.row-title { display:block; color:var(--ink); font-size:14px; line-height:1.5; font-weight:600; }
|
||||||
|
.row-meta { display:flex; align-items:center; gap:6px; margin-top:4px; color:var(--muted); font-size:12px; line-height:1.45; }
|
||||||
|
.dot { width:7px; height:7px; flex:none; margin-top:6px; border-radius:50%; background:var(--accent); }
|
||||||
|
.dot.online { background:var(--success); }
|
||||||
|
.dot.offline { background:#c7c2ba; }
|
||||||
|
.tag { display:inline-flex; align-items:center; border-radius:4px; padding:1px 5px; background:var(--accent-soft); color:var(--accent); font-size:10px; font-weight:600; }
|
||||||
|
.section-title { display:flex; align-items:baseline; justify-content:space-between; padding:0 2px 8px; }
|
||||||
|
.section-title h2 { margin:0; font-size:14px; font-weight:700; }
|
||||||
|
.section-title a { color:var(--accent); font-size:13px; font-weight:500; }
|
||||||
|
.stats { display:grid; overflow:hidden; border:1px solid var(--line); border-radius:var(--radius-lg); background:var(--surface); }
|
||||||
|
.stat { padding:11px 4px; text-align:center; }
|
||||||
|
.stat + .stat { border-left:1px solid var(--line); }
|
||||||
|
.stat strong { display:block; font-size:20px; line-height:1.2; font-weight:700; }
|
||||||
|
.stat:first-child strong, .stat.accent strong { color:var(--accent); }
|
||||||
|
.stat span { display:block; margin-top:3px; color:var(--muted); font-size:11px; }
|
||||||
|
.button-row { display:flex; gap:10px; }
|
||||||
|
.btn { display:inline-flex; min-height:40px; align-items:center; justify-content:center; gap:6px; border:1px solid var(--line-strong); border-radius:var(--radius-md); padding:0 14px; background:var(--surface); color:var(--ink); font-size:13.5px; font-weight:600; }
|
||||||
|
.btn:hover { background:var(--surface-soft); }
|
||||||
|
.btn.primary { border-color:var(--accent); background:var(--accent); color:#fff; }
|
||||||
|
.btn.primary:hover { background:var(--accent-hover); }
|
||||||
|
.btn.dark { border-color:var(--ink); background:var(--ink); color:#fff; }
|
||||||
|
.btn.small { min-height:32px; border-radius:8px; padding:0 12px; font-size:13px; }
|
||||||
|
.btn.block { width:100%; }
|
||||||
|
.segment { display:flex; padding:2px; border-radius:9px; background:rgba(32,29,26,.06); }
|
||||||
|
.segment a, .segment button { flex:1; height:30px; border:0; border-radius:7px; background:transparent; color:var(--muted); font-size:13px; font-weight:600; text-align:center; line-height:30px; }
|
||||||
|
.segment .active { background:#fff; color:var(--ink); box-shadow:0 1px 2px rgba(0,0,0,.06); }
|
||||||
|
.field { width:100%; height:44px; border:1px solid rgba(32,29,26,.12); border-radius:10px; padding:0 13px; outline:0; background:#fff; color:var(--ink); font-size:14px; }
|
||||||
|
.field:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-soft); }
|
||||||
|
textarea.field { height:auto; min-height:84px; padding:9px 11px; line-height:1.5; background:var(--surface-soft); }
|
||||||
|
.empty { padding:34px 20px; color:var(--muted); text-align:center; font-size:13px; line-height:1.7; }
|
||||||
|
.muted-note { margin:0; color:var(--muted-light); font-size:11.5px; line-height:1.6; }
|
||||||
|
.bottom-nav { position:fixed; z-index:20; right:0; bottom:0; left:0; display:grid; grid-template-columns:repeat(4,1fr); width:100%; max-width:430px; margin:0 auto; padding:6px 0 calc(10px + env(safe-area-inset-bottom)); border-top:1px solid var(--line); background:rgba(255,255,255,.96); backdrop-filter:blur(10px); }
|
||||||
|
.nav-item { position:relative; display:flex; flex-direction:column; align-items:center; gap:3px; padding:4px 0; color:var(--muted); font-size:10px; font-weight:600; }
|
||||||
|
.nav-item.active { color:var(--accent); }
|
||||||
|
.nav-badge { position:absolute; top:0; left:calc(50% + 6px); min-width:16px; height:16px; border-radius:8px; padding:0 4px; background:var(--accent); color:#fff; font-size:10px; line-height:16px; text-align:center; }
|
||||||
|
.device-icon { display:flex; width:40px; height:40px; flex:none; align-items:center; justify-content:center; border-radius:10px; background:rgba(192,90,30,.08); color:var(--accent); }
|
||||||
|
.timeline-item { padding:11px 14px; border-top:1px solid var(--line); }
|
||||||
|
.timeline-item:first-child { border-top:0; }
|
||||||
|
.timeline-top { display:flex; align-items:baseline; gap:8px; color:var(--muted); font-size:12px; font-weight:600; }
|
||||||
|
.timeline-time { margin-left:auto; color:var(--muted-light); font-size:11.5px; font-weight:400; white-space:nowrap; }
|
||||||
|
.timeline-body { margin:4px 0 0; color:var(--ink-soft); font-size:13.5px; line-height:1.55; }
|
||||||
|
.settings-controls > * { border:1px solid var(--line)!important; border-radius:12px!important; background:#fff!important; padding:14px!important; box-shadow:none!important; }
|
||||||
|
.settings-controls h2, .settings-controls h3 { font-family:var(--font-body)!important; font-size:15px!important; line-height:1.45!important; }
|
||||||
|
.settings-controls p { font-size:12px!important; line-height:1.65!important; }
|
||||||
|
.device-manage summary { list-style:none; }
|
||||||
|
.device-manage[open] summary { margin-bottom:10px; }
|
||||||
|
.device-manage > :not(summary) { border:1px solid var(--line)!important; border-radius:12px!important; background:#fff!important; padding:14px!important; box-shadow:none!important; }
|
||||||
|
.scan-panel > div { border:0!important; border-radius:0!important; background:transparent!important; padding:0!important; box-shadow:none!important; }
|
||||||
|
.scan-panel > div > p:first-child, .scan-panel > div > h2, .scan-panel > div > h2 + p { display:none!important; }
|
||||||
|
.scan-panel video { border-radius:14px; }
|
||||||
|
|
||||||
html {
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.45} }
|
||||||
background: #ecdfcf;
|
@keyframes route-progress-slide { 0%{transform:translateX(-140%)} 100%{transform:translateX(240%)} }
|
||||||
}
|
.route-progress-bar { animation:route-progress-slide 1.15s ease-in-out infinite; background:var(--accent)!important; }
|
||||||
|
|
||||||
body {
|
@media (min-width: 700px) {
|
||||||
color: var(--ink);
|
.app-frame { min-height:calc(100dvh - 32px); margin:16px auto; border-radius:18px; box-shadow:0 18px 60px rgba(32,29,26,.12); }
|
||||||
font-family: var(--font-body), sans-serif;
|
.app-content { min-height:calc(100dvh - 32px); }
|
||||||
background:
|
.bottom-nav { bottom:16px; border-radius:0 0 18px 18px; }
|
||||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.82), transparent 34%),
|
|
||||||
radial-gradient(circle at 88% 10%, rgba(205, 126, 76, 0.22), transparent 22%),
|
|
||||||
linear-gradient(180deg, #f6eee1 0%, #efe2d1 52%, #e8d6c3 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input,
|
|
||||||
textarea,
|
|
||||||
select {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
background: rgba(199, 103, 51, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes route-progress-slide {
|
|
||||||
0% {
|
|
||||||
transform: translateX(-140%) scaleX(0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
55% {
|
|
||||||
transform: translateX(78%) scaleX(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: translateX(240%) scaleX(0.86);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-progress-bar {
|
|
||||||
animation: route-progress-slide 1.15s ease-in-out infinite;
|
|
||||||
transform-origin: left center;
|
|
||||||
box-shadow: 0 0 18px rgba(199, 103, 51, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.paper-panel {
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 249, 241, 0.84)),
|
|
||||||
repeating-linear-gradient(
|
|
||||||
135deg,
|
|
||||||
rgba(198, 163, 127, 0.06),
|
|
||||||
rgba(198, 163, 127, 0.06) 10px,
|
|
||||||
rgba(255, 255, 255, 0.08) 10px,
|
|
||||||
rgba(255, 255, 255, 0.08) 20px
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: "#FBFAF6",
|
themeColor: "#F4F3F1",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@ -1,78 +1,26 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { AccountAuthForm } from "@/components/account-auth-form";
|
import { AccountAuthForm } from "@/components/account-auth-form";
|
||||||
import {
|
import { getCaregiverSession, sanitizeCaregiverRedirectPath } from "@/lib/session";
|
||||||
getCaregiverSession,
|
|
||||||
sanitizeCaregiverRedirectPath,
|
|
||||||
} from "@/lib/session";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type LoginPageProps = {
|
export default async function LoginPage({ searchParams }: PageProps<"/login">) {
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|
||||||
const caregiver = await getCaregiverSession();
|
const caregiver = await getCaregiverSession();
|
||||||
const params = await searchParams;
|
const raw = (await searchParams).redirectTo;
|
||||||
const redirectTo = sanitizeCaregiverRedirectPath(
|
const redirectTo = sanitizeCaregiverRedirectPath(typeof raw === "string" ? raw : null);
|
||||||
typeof params.redirectTo === "string" ? params.redirectTo : null,
|
if (caregiver) redirect(redirectTo);
|
||||||
);
|
|
||||||
|
|
||||||
if (caregiver) {
|
|
||||||
redirect(redirectTo);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen max-w-6xl items-center px-4 py-8 sm:px-6 lg:px-8">
|
<main className="app-frame" style={{ display: "flex", alignItems: "center" }}>
|
||||||
<section className="grid w-full gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
<section style={{ width: "100%", padding: "28px 22px", display: "flex", flexDirection: "column", gap: 22 }}>
|
||||||
<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>
|
||||||
<div className="absolute -right-10 top-[-40px] h-44 w-44 rounded-full bg-white/10 blur-2xl" />
|
<div style={{ display: "flex", width: 52, height: 52, alignItems: "center", justifyContent: "center", borderRadius: 14, background: "var(--accent)", color: "#fff", fontSize: 22, fontWeight: 700 }}>安</div>
|
||||||
<div className="absolute bottom-[-48px] left-[-12px] h-40 w-40 rounded-full border border-white/16" />
|
<h1 style={{ margin: "16px 0 0", fontSize: 24, lineHeight: 1.35 }}>安智伴 家属端</h1>
|
||||||
|
<p style={{ margin: "8px 0 0", color: "var(--muted)", fontSize: 13.5, lineHeight: 1.7 }}>用同一个账号,在每一台设备上继续守护。长辈的留言、对话与动态都按账号归属。</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<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} />
|
<AccountAuthForm redirectTo={redirectTo} />
|
||||||
</div>
|
<p className="muted-note" style={{ textAlign: "center" }}>登录后会自动回到刚才的页面,扫码和绑定流程不用重新开始。</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,119 +3,35 @@ import { notFound } from "next/navigation";
|
|||||||
|
|
||||||
import { PanelShell } from "@/components/panel-shell";
|
import { PanelShell } from "@/components/panel-shell";
|
||||||
import { getCaregiverMessageDetail } from "@/lib/caregiver-panel";
|
import { getCaregiverMessageDetail } from "@/lib/caregiver-panel";
|
||||||
import {
|
import { formatDateTime, getDeviceName, getMessageDirectionLabel, getMessageStatusLabel } from "@/lib/panel-format";
|
||||||
formatDateTime,
|
|
||||||
getDeviceName,
|
|
||||||
getImportanceLabel,
|
|
||||||
getMessageDirectionLabel,
|
|
||||||
getMessageStatusLabel,
|
|
||||||
} from "@/lib/panel-format";
|
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
import { getCaregiverDisplayName } from "@/lib/session";
|
import { getCaregiverDisplayName } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type MessageDetailPageProps = {
|
export default async function MessageDetailPage({ params }: PageProps<"/messages/[messageId]">) {
|
||||||
params: Promise<{ messageId: string }>;
|
const publicId = Number.parseInt((await params).messageId, 10);
|
||||||
};
|
if (!Number.isFinite(publicId) || publicId <= 0) notFound();
|
||||||
|
|
||||||
export default async function MessageDetailPage({ params }: MessageDetailPageProps) {
|
|
||||||
const { messageId } = await params;
|
|
||||||
const publicId = Number.parseInt(messageId, 10);
|
|
||||||
|
|
||||||
if (!Number.isFinite(publicId) || publicId <= 0) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const caregiver = await requireCaregiverSession(`/messages/${publicId}`);
|
const caregiver = await requireCaregiverSession(`/messages/${publicId}`);
|
||||||
const message = await getCaregiverMessageDetail(caregiver.id, publicId);
|
const message = await getCaregiverMessageDetail(caregiver.id, publicId);
|
||||||
|
if (!message) notFound();
|
||||||
if (!message) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell currentPath="/messages" title={`留言 #${message.publicId}`} caregiverLabel={getCaregiverDisplayName(caregiver)} backHref="/messages">
|
||||||
currentPath="/messages"
|
<article className="card card-pad" style={{ paddingTop: 16, paddingBottom: 16 }}>
|
||||||
title={`留言 #${message.publicId}`}
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||||
description="查看这条留言的完整内容和设备信息。"
|
<span className="tag" style={{ color: "var(--muted)", background: "rgba(32,29,26,.06)" }}>{getMessageDirectionLabel(message.direction)}</span>
|
||||||
caregiverLabel={getCaregiverDisplayName(caregiver)}
|
{message.importance !== "NORMAL" ? <span className="tag">重要</span> : null}
|
||||||
>
|
<span className="tag" style={{ color: "var(--muted)", background: "rgba(32,29,26,.06)" }}>{getMessageStatusLabel(message)}</span>
|
||||||
<section className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
|
||||||
<article className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted)]">
|
|
||||||
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1 font-semibold text-[var(--ink)]">
|
|
||||||
#{message.publicId}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
|
|
||||||
{getMessageDirectionLabel(message.direction)}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
|
|
||||||
{getImportanceLabel(message.importance)}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
|
|
||||||
{getMessageStatusLabel(message)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p style={{ margin: "12px 0 0", fontSize: 17, lineHeight: 1.65 }}>{message.content}</p>
|
||||||
<h2 className="mt-5 font-[family-name:var(--font-display)] text-4xl leading-[1.2] text-[var(--ink)]">
|
|
||||||
{message.content}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{message.recipientRelation ? (
|
|
||||||
<p className="mt-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
目标对象:{message.recipientRelation}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</article>
|
</article>
|
||||||
|
<section className="card">
|
||||||
<aside className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
<div className="list-row"><span style={{ color: "var(--muted)" }}>所属设备</span><strong style={{ marginLeft: "auto", fontSize: 13 }}>{getDeviceName(message.elderDevice.displayName)}</strong></div>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<div className="list-row"><span style={{ color: "var(--muted)" }}>时间</span><strong style={{ marginLeft: "auto", fontSize: 13 }}>{formatDateTime(message.createdAt)}</strong></div>
|
||||||
设备信息
|
<div className="list-row"><span style={{ color: "var(--muted)" }}>送达方式</span><strong style={{ marginLeft: "auto", fontSize: 13 }}>数字人口播</strong></div>
|
||||||
</p>
|
|
||||||
<div className="mt-5 space-y-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<p>所属设备</p>
|
|
||||||
<p className="mt-2 font-semibold text-[var(--ink)]">
|
|
||||||
{getDeviceName(message.elderDevice.displayName)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<p>设备标识</p>
|
|
||||||
<p className="mt-2 break-all font-semibold text-[var(--ink)]">
|
|
||||||
{message.elderDevice.deviceUuid}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<p>创建时间</p>
|
|
||||||
<p className="mt-2 font-semibold text-[var(--ink)]">
|
|
||||||
{formatDateTime(message.createdAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<p>最近在线</p>
|
|
||||||
<p className="mt-2 font-semibold text-[var(--ink)]">
|
|
||||||
{formatDateTime(message.elderDevice.lastSeenAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
|
||||||
<Link
|
|
||||||
href="/messages"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
|
||||||
>
|
|
||||||
返回留言板
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/devices/${message.elderDevice.deviceUuid}`}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
查看设备
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</section>
|
</section>
|
||||||
|
<Link href={`/devices/${message.elderDevice.deviceUuid}`} className="btn primary block">回一句问候</Link>
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,145 +1,43 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { PenLine } from "lucide-react";
|
||||||
|
|
||||||
import { PanelShell } from "@/components/panel-shell";
|
import { PanelShell } from "@/components/panel-shell";
|
||||||
import { getCaregiverMessages } from "@/lib/caregiver-panel";
|
import { getCaregiverMessages } from "@/lib/caregiver-panel";
|
||||||
import {
|
import { formatDateTime, getDeviceName, getMessageHref, getMessageStatusLabel, truncateText } from "@/lib/panel-format";
|
||||||
formatDateTime,
|
|
||||||
getDeviceName,
|
|
||||||
getImportanceLabel,
|
|
||||||
getMessageDirectionLabel,
|
|
||||||
getMessageHref,
|
|
||||||
getMessageStatusLabel,
|
|
||||||
truncateText,
|
|
||||||
} from "@/lib/panel-format";
|
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
import { getCaregiverDisplayName } from "@/lib/session";
|
import { getCaregiverDisplayName } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function MessagesPage() {
|
export default async function MessagesPage({ searchParams }: PageProps<"/messages">) {
|
||||||
const caregiver = await requireCaregiverSession("/messages");
|
const caregiver = await requireCaregiverSession("/messages");
|
||||||
const { messages, unreadIncomingCount, unreadOutgoingCount } =
|
const { messages, unreadIncomingCount } = await getCaregiverMessages(caregiver.id);
|
||||||
await getCaregiverMessages(caregiver.id);
|
const filter = (await searchParams).filter === "out" ? "out" : "in";
|
||||||
|
const items = messages.filter((m) => filter === "in" ? m.direction === "ELDER_TO_FAMILY" : m.direction === "FAMILY_TO_ELDER");
|
||||||
const incomingMessages = messages.filter(
|
|
||||||
(message) => message.direction === "ELDER_TO_FAMILY",
|
|
||||||
);
|
|
||||||
const outgoingMessages = messages.filter(
|
|
||||||
(message) => message.direction === "FAMILY_TO_ELDER",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell currentPath="/messages" title="留言板" caregiverLabel={getCaregiverDisplayName(caregiver)} unreadCount={unreadIncomingCount}
|
||||||
currentPath="/messages"
|
actions={<Link href="/devices" className="btn primary small"><PenLine size={14}/>写留言</Link>}>
|
||||||
title="留言板"
|
<div className="segment">
|
||||||
description="长辈捆来的话和您发出的问候,都在这里。"
|
<Link href="/messages?filter=in" className={filter === "in" ? "active" : ""}>收到 · {unreadIncomingCount} 未读</Link>
|
||||||
caregiverLabel={getCaregiverDisplayName(caregiver)}
|
<Link href="/messages?filter=out" className={filter === "out" ? "active" : ""}>发出</Link>
|
||||||
>
|
|
||||||
<section className="grid gap-4 md:grid-cols-3">
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">全部留言</p>
|
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{messages.length}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
<section className="card">
|
||||||
<p className="text-sm text-[var(--muted)]">长辈的未读留言</p>
|
{items.length ? items.map((message) => (
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<Link key={message.id} href={getMessageHref(message.publicId)} className="list-row">
|
||||||
{unreadIncomingCount}
|
<span className="dot" style={{ background: message.readAt ? "transparent" : "var(--accent)" }} />
|
||||||
</p>
|
<span className="row-main">
|
||||||
</div>
|
<span className="row-meta" style={{ marginTop: 0 }}>
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
<strong style={{ color: "var(--ink-soft)" }}>{getDeviceName(message.elderDevice.displayName)}</strong>
|
||||||
<p className="text-sm text-[var(--muted)]">待长辈查看</p>
|
{message.importance !== "NORMAL" ? <span className="tag">重要</span> : null}
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<span style={{ marginLeft: "auto", color: "var(--muted-light)" }}>{formatDateTime(message.createdAt)}</span>
|
||||||
{unreadOutgoingCount}
|
</span>
|
||||||
</p>
|
<span className="row-title" style={{ marginTop: 4, fontWeight: message.readAt ? 400 : 600 }}>{truncateText(message.content, 82)}</span>
|
||||||
</div>
|
<span className="row-meta">#{message.publicId} · {getMessageStatusLabel(message)}</span>
|
||||||
</section>
|
|
||||||
|
|
||||||
{messages.length === 0 ? (
|
|
||||||
<section className="rounded-[32px] border border-dashed border-[var(--line)] bg-white/72 px-6 py-10 text-center shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
|
||||||
<h2 className="font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
还没有留言
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
|
||||||
连接长辈的设备后,就可以在这里查看长辈的留言,也可以给长辈写问候。
|
|
||||||
</p>
|
|
||||||
<div className="mt-6 flex justify-center gap-3">
|
|
||||||
<Link
|
|
||||||
href="/devices"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
|
||||||
>
|
|
||||||
连接设备
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/scan"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
扫码连接
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<section className="grid gap-6 xl:grid-cols-2">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
title: "长辈的留言",
|
|
||||||
subtitle: "长辈通过智能助理捆来的话。",
|
|
||||||
items: incomingMessages,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "我发出的问候",
|
|
||||||
subtitle: "您写给长辈的每一条问候。",
|
|
||||||
items: outgoingMessages,
|
|
||||||
},
|
|
||||||
].map((section) => (
|
|
||||||
<div
|
|
||||||
key={section.title}
|
|
||||||
className="rounded-[32px] border border-white/75 bg-white/80 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]"
|
|
||||||
>
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
{section.title === "长辈的留言" ? "收到的" : "发出的"}
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
{section.title}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">{section.subtitle}</p>
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{section.items.length === 0 ? (
|
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
这一侧暂时还没有留言。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
section.items.map((message) => (
|
|
||||||
<Link
|
|
||||||
key={message.id}
|
|
||||||
href={getMessageHref(message.publicId)}
|
|
||||||
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
|
|
||||||
<span className="rounded-full bg-white px-3 py-1 font-semibold text-[var(--ink)]">
|
|
||||||
#{message.publicId}
|
|
||||||
</span>
|
</span>
|
||||||
<span>{getMessageDirectionLabel(message.direction)}</span>
|
|
||||||
<span>{getDeviceName(message.elderDevice.displayName)}</span>
|
|
||||||
<span>{getImportanceLabel(message.importance)}</span>
|
|
||||||
<span>{getMessageStatusLabel(message)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(message.content, 112)}
|
|
||||||
</p>
|
|
||||||
<p className="mt-3 text-xs text-[var(--muted)]">
|
|
||||||
创建于 {formatDateTime(message.createdAt)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
</Link>
|
||||||
))
|
)) : <div className="empty">这一侧暂时还没有留言。</div>}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
273
app/page.tsx
273
app/page.tsx
@ -1,247 +1,86 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { MessageCircle, Smartphone, Activity, ScanLine } from "lucide-react";
|
import { ChevronRight, PenLine, ScanLine } from "lucide-react";
|
||||||
|
|
||||||
import { PanelShell } from "@/components/panel-shell";
|
import { PanelShell } from "@/components/panel-shell";
|
||||||
import { getCaregiverOverview } from "@/lib/caregiver-panel";
|
import { getCaregiverOverview } from "@/lib/caregiver-panel";
|
||||||
import {
|
import { formatDateTime, getDeviceName, getMessageHref, truncateText } from "@/lib/panel-format";
|
||||||
formatDateTime,
|
|
||||||
getDeviceName,
|
|
||||||
getImportanceLabel,
|
|
||||||
getMessageHref,
|
|
||||||
truncateText,
|
|
||||||
} from "@/lib/panel-format";
|
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
import { getCaregiverDisplayName } from "@/lib/session";
|
import { getCaregiverDisplayName } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function greeting() {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour < 11) return "早上好";
|
||||||
|
if (hour < 14) return "中午好";
|
||||||
|
if (hour < 18) return "下午好";
|
||||||
|
return "晚上好";
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const caregiver = await requireCaregiverSession("/");
|
const caregiver = await requireCaregiverSession("/");
|
||||||
const { dashboard, recentIncomingMessages, recentOutgoingMessages } =
|
const { dashboard, recentIncomingMessages } = await getCaregiverOverview(caregiver.id);
|
||||||
await getCaregiverOverview(caregiver.id);
|
const name = getCaregiverDisplayName(dashboard.caregiver);
|
||||||
|
const unreadIn = dashboard.devices.reduce((n, d) => n + d.elderUnreadCount, 0);
|
||||||
const totalUnreadForElder = dashboard.devices.reduce(
|
const unreadOut = dashboard.devices.reduce((n, d) => n + d.familyUnreadCount, 0);
|
||||||
(total, device) => total + device.familyUnreadCount,
|
const conversations = dashboard.devices.reduce((n, d) => n + d.elderDevice._count.conversationTurns, 0);
|
||||||
0,
|
const date = new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", weekday: "short" }).format(new Date());
|
||||||
);
|
|
||||||
const totalUnreadForCaregiver = dashboard.devices.reduce(
|
|
||||||
(total, device) => total + device.elderUnreadCount,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const totalConversations = dashboard.devices.reduce(
|
|
||||||
(total, device) => total + device.elderDevice._count.conversationTurns,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
currentPath="/"
|
currentPath="/"
|
||||||
title="今日概览"
|
title={`${greeting()},${name}`}
|
||||||
description="随时了解长辈的近况,留言和动态一目了然。"
|
description={`${date} · ${dashboard.devices.length} 台设备已连接`}
|
||||||
caregiverLabel={getCaregiverDisplayName(dashboard.caregiver)}
|
caregiverLabel={name}
|
||||||
|
unreadCount={unreadIn}
|
||||||
>
|
>
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<section className="card">
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
{dashboard.devices.length ? dashboard.devices.map((binding) => (
|
||||||
<p className="text-sm text-[var(--muted)]">已连接设备</p>
|
<Link key={binding.id} href={`/devices/${binding.elderDevice.deviceUuid}`} className="list-row" style={{ alignItems: "center" }}>
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<span className={`dot ${binding.elderDevice.lastSeenAt ? "online" : "offline"}`} style={{ marginTop: 0 }} />
|
||||||
{dashboard.devices.length}
|
<span className="row-title" style={{ flex: 1 }}>{getDeviceName(binding.alias, binding.elderDevice.displayName)}</span>
|
||||||
</p>
|
<span className="row-meta" style={{ marginTop: 0 }}>{binding.elderDevice.lastSeenAt ? "在线" : "未连接"}</span>
|
||||||
</div>
|
<ChevronRight size={14} color="var(--muted-light)" />
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
</Link>
|
||||||
<p className="text-sm text-[var(--muted)]">长辈的新留言</p>
|
)) : <div className="empty">还没有连接设备,扫码即可开始守护。</div>}
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{totalUnreadForCaregiver}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
|
||||||
<p className="text-sm text-[var(--muted)]">待长辈查看</p>
|
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{totalUnreadForElder}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
|
||||||
<p className="text-sm text-[var(--muted)]">陪伴对话</p>
|
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{totalConversations}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-4 lg:grid-cols-4">
|
<section className="stats" style={{ gridTemplateColumns: "repeat(4,1fr)" }}>
|
||||||
{[
|
<Link href="/messages" className="stat accent"><strong>{unreadIn}</strong><span>新留言</span></Link>
|
||||||
{
|
<Link href="/messages" className="stat"><strong>{unreadOut}</strong><span>待长辈听</span></Link>
|
||||||
href: "/messages",
|
<Link href="/activity" className="stat"><strong>{conversations}</strong><span>陪伴对话</span></Link>
|
||||||
title: "留言板",
|
<Link href="/devices" className="stat"><strong>{dashboard.devices.length}</strong><span>设备</span></Link>
|
||||||
text: "查看长辈捎来的话,也能回看您发出的每一条问候。",
|
|
||||||
tone: "bg-[rgba(53,84,141,0.12)]",
|
|
||||||
icon: MessageCircle,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/devices",
|
|
||||||
title: "我的设备",
|
|
||||||
text: "管理已绑定的长辈设备,进入设备详情给长辈写留言。",
|
|
||||||
tone: "bg-[rgba(118,132,94,0.14)]",
|
|
||||||
icon: Smartphone,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/activity",
|
|
||||||
title: "陪伴动态",
|
|
||||||
text: "了解长辈最近的使用情况和聊天记录,安心守护。",
|
|
||||||
tone: "bg-[rgba(199,103,51,0.12)]",
|
|
||||||
icon: Activity,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/scan",
|
|
||||||
title: "扫码绑定",
|
|
||||||
text: "扫描长辈手机上的二维码,一步完成设备连接。",
|
|
||||||
tone: "bg-[rgba(36,27,20,0.08)]",
|
|
||||||
icon: ScanLine,
|
|
||||||
},
|
|
||||||
].map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className="rounded-[28px] border border-white/80 bg-white/84 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)] transition hover:-translate-y-0.5"
|
|
||||||
>
|
|
||||||
<div className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-semibold text-[var(--ink)] ${item.tone}`}>
|
|
||||||
<Icon size={14} />
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
<h2 className="mt-4 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
|
||||||
{item.title}
|
|
||||||
</h2>
|
|
||||||
<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 ? (
|
<div className="button-row">
|
||||||
<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)]">
|
<Link href={dashboard.devices[0] ? `/devices/${dashboard.devices[0].elderDevice.deviceUuid}` : "/devices"} className="btn primary" style={{ flex: 1 }}>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<PenLine size={15} />给长辈留言
|
||||||
开始使用
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-3 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
先连接长辈的设备
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
|
||||||
让长辈打开手机里的「家人连接」页面,用您的手机扫描二维码,即可建立连接。连接成功后,您就能收到长辈的留言和使用动态。
|
|
||||||
</p>
|
|
||||||
<div className="mt-6 flex justify-center gap-3">
|
|
||||||
<Link
|
|
||||||
href="/scan"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
|
||||||
>
|
|
||||||
扫码连接
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/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>
|
|
||||||
) : (
|
|
||||||
<section className="grid gap-6 xl:grid-cols-2">
|
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/80 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
长辈的留言
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
最近收到的话
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/messages"
|
|
||||||
className="inline-flex h-11 items-center justify-center rounded-full border border-[var(--line)] bg-white px-4 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
查看全部
|
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/scan" className="btn" style={{ flex: 1 }}><ScanLine size={15} />扫码绑定</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
<section>
|
||||||
{recentIncomingMessages.length === 0 ? (
|
<div className="section-title"><h2>长辈的新留言</h2><Link href="/messages">全部</Link></div>
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
<div className="card">
|
||||||
暂时还没有收到新留言。
|
{recentIncomingMessages.length ? recentIncomingMessages.slice(0, 3).map((message) => (
|
||||||
</p>
|
<Link key={message.id} href={getMessageHref(message.publicId)} className="list-row">
|
||||||
) : (
|
<span className="dot" />
|
||||||
recentIncomingMessages.map((message) => (
|
<span className="row-main">
|
||||||
<Link
|
<span className="row-title">{truncateText(message.content, 62)}</span>
|
||||||
key={message.id}
|
<span className="row-meta">{message.importance !== "NORMAL" ? <span className="tag">重要</span> : null}{getDeviceName(message.elderDevice.displayName)} · {formatDateTime(message.createdAt)}</span>
|
||||||
href={getMessageHref(message.publicId)}
|
|
||||||
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
|
|
||||||
<span className="rounded-full bg-white px-3 py-1 font-semibold text-[var(--ink)]">
|
|
||||||
#{message.publicId}
|
|
||||||
</span>
|
</span>
|
||||||
<span>{getDeviceName(message.elderDevice.displayName)}</span>
|
|
||||||
<span>{getImportanceLabel(message.importance)}</span>
|
|
||||||
<span>{formatDateTime(message.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(message.content)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
</Link>
|
||||||
))
|
)) : <div className="empty">暂时还没有收到新留言。</div>}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
|
<section>
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/80 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
<div className="section-title"><h2>陪伴动态</h2><Link href="/activity">全部</Link></div>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="card">
|
||||||
<div>
|
<div className="timeline-item"><div className="timeline-top">设备连接情况<span className="timeline-time">现在</span></div><p className="timeline-body">{dashboard.devices.length} 台长辈设备已加入家人守护。</p></div>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<div className="timeline-item"><div className="timeline-top">陪伴对话<span className="timeline-time">累计</span></div><p className="timeline-body">数字人已完成 {conversations} 轮陪伴对话。</p></div>
|
||||||
我发出的问候
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
|
||||||
最近写给长辈的话
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/devices"
|
|
||||||
className="inline-flex h-11 items-center justify-center rounded-full border border-[var(--line)] bg-white px-4 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
写新留言
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{recentOutgoingMessages.length === 0 ? (
|
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
还没有给长辈留过言,去设备页写一句问候吧。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
recentOutgoingMessages.map((message) => (
|
|
||||||
<Link
|
|
||||||
key={message.id}
|
|
||||||
href={getMessageHref(message.publicId)}
|
|
||||||
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
|
|
||||||
<span className="rounded-full bg-white px-3 py-1 font-semibold text-[var(--ink)]">
|
|
||||||
#{message.publicId}
|
|
||||||
</span>
|
|
||||||
<span>{getDeviceName(message.elderDevice.displayName)}</span>
|
|
||||||
<span>{getImportanceLabel(message.importance)}</span>
|
|
||||||
<span>{formatDateTime(message.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(message.content)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,51 +9,15 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export default async function ScanPage() {
|
export default async function ScanPage() {
|
||||||
const caregiver = await requireCaregiverSession("/scan");
|
const caregiver = await requireCaregiverSession("/scan");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell currentPath="/scan" title="扫码绑定" caregiverLabel={getCaregiverDisplayName(caregiver)} backHref="/devices">
|
||||||
currentPath="/scan"
|
<div className="scan-panel"><ScanClient /></div>
|
||||||
title="扫码连接"
|
<section className="card card-pad" style={{ color: "var(--muted)", fontSize: 12.5, lineHeight: 1.7 }}>
|
||||||
description="把镜头对准长辈手机上的二维码,识别后会自动完成连接并跳转到绑定结果。"
|
<p style={{ margin: 0 }}>1. 让长辈打开陪伴屏上的「家人连接」页面。</p>
|
||||||
caregiverLabel={getCaregiverDisplayName(caregiver)}
|
<p style={{ margin: "4px 0 0" }}>2. 将镜头对准屏幕上的二维码,保持一两秒。</p>
|
||||||
>
|
<p style={{ margin: "4px 0 0" }}>3. 识别成功后自动完成绑定并返回设备页。</p>
|
||||||
<section className="grid gap-6 xl:grid-cols-[0.88fr_1.12fr]">
|
|
||||||
<article 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>
|
|
||||||
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
|
|
||||||
扫描成功后,系统会自动把长辈的设备连接到当前账号。连接完成后,就能继续查看留言、动态和设备详情。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-3 rounded-[24px] bg-[var(--paper-soft)] p-5 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
<p>1. 让长辈打开手机上的「家人连接」页面。</p>
|
|
||||||
<p>2. 将镜头对准屏幕上的二维码,保持一两秒即可。</p>
|
|
||||||
<p>3. 连接成功后会自动跳转。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<p className="text-sm font-semibold text-[var(--ink)]">小提醒</p>
|
|
||||||
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
如果当前设备没有摄像头,或者浏览器没有拿到拍照权限,可以回到设备页,改用粘贴设备码的方式继续连接。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
|
||||||
<Link
|
|
||||||
href="/devices"
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<ScanClient />
|
|
||||||
</section>
|
</section>
|
||||||
|
<Link href="/devices" className="btn block">没有摄像头?改用设备码连接</Link>
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -10,36 +10,22 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const caregiver = await requireCaregiverSession("/settings");
|
const caregiver = await requireCaregiverSession("/settings");
|
||||||
const overview = await getCaregiverSettingsOverview(caregiver.id);
|
const overview = await getCaregiverSettingsOverview(caregiver.id);
|
||||||
|
const name = getCaregiverDisplayName(caregiver);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell currentPath="/settings" title="我的" caregiverLabel={name}>
|
||||||
currentPath="/settings"
|
<section className="card card-pad" style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
title="设置"
|
<span className="avatar" style={{ width: 44, height: 44, fontSize: 17 }}>{name.slice(-1)}</span>
|
||||||
description="PWA 桌面版安装,安装后即可接收实时消息推送。"
|
<span className="row-main"><strong style={{ display: "block", fontSize: 15 }}>{name}</strong><span className="row-meta">已绑定 {overview.deviceCount} 台设备 · {overview.pushSubscriptionCount} 台设备已开启提醒</span></span>
|
||||||
caregiverLabel={getCaregiverDisplayName(caregiver)}
|
|
||||||
>
|
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
|
||||||
<p className="text-sm text-[var(--muted)]">已连接设备</p>
|
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{overview.deviceCount}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
|
||||||
<p className="text-sm text-[var(--muted)]">已开启提醒的设备</p>
|
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{overview.pushSubscriptionCount}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-6 xl:grid-cols-2">
|
<div className="settings-controls"><PwaControls /></div>
|
||||||
<PwaControls />
|
<div className="settings-controls"><PushTestPanel initialSubscriptionCount={overview.pushSubscriptionCount} deviceCount={overview.deviceCount} /></div>
|
||||||
<PushTestPanel
|
|
||||||
initialSubscriptionCount={overview.pushSubscriptionCount}
|
<form action="/api/account/logout" method="post" className="card">
|
||||||
deviceCount={overview.deviceCount}
|
<button type="submit" className="list-row" style={{ justifyContent: "center", color: "var(--danger)", fontWeight: 500 }}>退出登录</button>
|
||||||
/>
|
</form>
|
||||||
</section>
|
<p className="muted-note" style={{ textAlign: "center" }}>安智伴 · AI 数字人陪护 · 家属端 v2</p>
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -87,8 +87,8 @@ export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="segment">
|
||||||
{[
|
{[
|
||||||
{ id: "login", label: "已有账号,直接登录" },
|
{ id: "login", label: "已有账号,直接登录" },
|
||||||
{ id: "register", label: "第一次使用,创建账号" },
|
{ id: "register", label: "第一次使用,创建账号" },
|
||||||
@ -103,11 +103,7 @@ export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) {
|
|||||||
setMode(item.id as AuthMode);
|
setMode(item.id as AuthMode);
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
}}
|
}}
|
||||||
className={`inline-flex h-11 items-center justify-center rounded-full px-4 text-sm font-semibold transition ${
|
className={active ? "active" : ""}
|
||||||
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}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
@ -115,28 +111,21 @@ export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
<form style={{ display: "flex", flexDirection: "column", gap: 10, marginTop: 12 }} onSubmit={handleSubmit}>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-semibold text-[var(--ink)]" htmlFor="caregiver-username">
|
<label className="sr-only" htmlFor="caregiver-username">用户名</label>
|
||||||
用户名
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="caregiver-username"
|
id="caregiver-username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
placeholder="例如:family_guardian"
|
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"
|
className="field"
|
||||||
/>
|
/>
|
||||||
<p className="mt-2 text-xs leading-6 text-[var(--muted)]">
|
|
||||||
建议使用 3 到 24 位字母、数字、下划线或横线,方便全家统一登录。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-semibold text-[var(--ink)]" htmlFor="caregiver-password">
|
<label className="sr-only" htmlFor="caregiver-password">密码</label>
|
||||||
密码
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="caregiver-password"
|
id="caregiver-password"
|
||||||
type="password"
|
type="password"
|
||||||
@ -144,18 +133,13 @@ export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) {
|
|||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||||
placeholder="请输入密码"
|
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"
|
className="field"
|
||||||
/>
|
/>
|
||||||
<p className="mt-2 text-xs leading-6 text-[var(--muted)]">
|
|
||||||
密码至少 8 位。同一账号可以在多台家属设备登录,消息提醒会同步到所有已登录设备。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === "register" ? (
|
{mode === "register" ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-semibold text-[var(--ink)]" htmlFor="caregiver-password-confirm">
|
<label className="sr-only" htmlFor="caregiver-password-confirm">再输入一次密码</label>
|
||||||
再输入一次密码
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="caregiver-password-confirm"
|
id="caregiver-password-confirm"
|
||||||
type="password"
|
type="password"
|
||||||
@ -163,7 +147,7 @@ export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) {
|
|||||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
placeholder="再次输入密码"
|
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"
|
className="field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -171,7 +155,8 @@ export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
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"
|
className="btn primary block"
|
||||||
|
style={{ minHeight: 44 }}
|
||||||
>
|
>
|
||||||
{submitting
|
{submitting
|
||||||
? mode === "login"
|
? mode === "login"
|
||||||
@ -184,7 +169,7 @@ export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{notice ? (
|
{notice ? (
|
||||||
<p className="mt-4 rounded-[20px] bg-[var(--paper-soft)] px-4 py-3 text-sm leading-7 text-[var(--muted)]">
|
<p style={{ margin: "10px 0 0", borderRadius: 8, background: "var(--surface)", padding: "10px 12px", color: "var(--muted)", fontSize: 12, lineHeight: 1.6 }}>
|
||||||
{notice}
|
{notice}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -31,45 +31,28 @@ export function BindDeviceForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[28px] border border-white/70 bg-white/80 p-5 shadow-[0_24px_60px_rgba(125,80,46,0.12)] backdrop-blur">
|
<div className="card card-pad">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
<p style={{ margin: 0, fontSize: 13, fontWeight: 600 }}>用设备码添加</p>
|
||||||
<div className="max-w-xl">
|
<form className="button-row" style={{ marginTop: 9 }} onSubmit={handleSubmit}>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
添加设备
|
|
||||||
</p>
|
|
||||||
<h3 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
|
||||||
扫码或粘贴设备码连接长辈
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
让长辈打开手机上的「家人连接」页面,扫描二维码最快;也可以将设备码粘贴到下方。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="mt-5 flex flex-col gap-3 lg:flex-row" onSubmit={handleSubmit}>
|
|
||||||
<input
|
<input
|
||||||
value={rawCode}
|
value={rawCode}
|
||||||
onChange={(event) => setRawCode(event.target.value)}
|
onChange={(event) => setRawCode(event.target.value)}
|
||||||
placeholder="粘贴设备码或二维码链接"
|
placeholder="粘贴长辈屏幕下方的设备码"
|
||||||
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="field"
|
||||||
|
style={{ height: 36, flex: 1, minWidth: 0 }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="inline-flex h-13 min-w-36 items-center justify-center rounded-full bg-[var(--olive)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--olive-deep)] disabled:cursor-not-allowed disabled:opacity-60"
|
className="btn dark"
|
||||||
|
style={{ minHeight: 36, flex: "none" }}
|
||||||
>
|
>
|
||||||
{submitting ? "正在连接" : "立即绑定"}
|
{submitting ? "连接中" : "连接"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{notice ? (
|
{notice ? (
|
||||||
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
|
<p style={{ margin: "8px 0 0", whiteSpace: "pre-line", color: "var(--success)", fontSize: 12 }}>
|
||||||
{notice}
|
{notice}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@ -142,20 +125,14 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="rounded-[28px] border border-white/70 bg-white/88 p-5" onSubmit={handleSubmit}>
|
<form className="card card-pad" onSubmit={handleSubmit}>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||||
<div>
|
<h4 style={{ margin: 0, fontSize: 13, fontWeight: 600 }}>给长辈留言</h4>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
留言
|
|
||||||
</p>
|
|
||||||
<h4 className="mt-2 font-[family-name:var(--font-display)] text-xl text-[var(--ink)]">
|
|
||||||
给长辈写一句问候
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<select
|
<select
|
||||||
value={importance}
|
value={importance}
|
||||||
onChange={(event) => setImportance(event.target.value)}
|
onChange={(event) => setImportance(event.target.value)}
|
||||||
className="h-11 rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 text-sm text-[var(--ink)] outline-none"
|
className="field"
|
||||||
|
style={{ width: "auto", height: 32, padding: "0 8px", fontSize: 12 }}
|
||||||
>
|
>
|
||||||
<option value="LOW">温和提醒</option>
|
<option value="LOW">温和提醒</option>
|
||||||
<option value="NORMAL">日常问候</option>
|
<option value="NORMAL">日常问候</option>
|
||||||
@ -169,24 +146,22 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
onChange={(event) => setContent(event.target.value)}
|
onChange={(event) => setContent(event.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder="例如:妈,晚上天气凉,记得添件衣服。晚饭后我给您打电话。"
|
placeholder="例如:妈,晚上天气凉,记得添件衣服。晚饭后我给您打电话。"
|
||||||
className="mt-4 w-full rounded-[24px] border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-4 text-sm leading-7 text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
|
className="field"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", marginTop: 9 }}>
|
||||||
<p className="text-sm leading-7 text-[var(--muted)]">
|
|
||||||
长辈和智能助理聊天时,就能看到这条留言。
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="inline-flex h-12 min-w-32 items-center justify-center rounded-full bg-[var(--ink)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--ink-soft)] disabled:cursor-not-allowed disabled:opacity-60"
|
className="btn primary small"
|
||||||
>
|
>
|
||||||
{submitting ? "正在送达" : "写好并送出"}
|
{submitting ? "正在送达" : "写好并送出"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{notice ? (
|
{notice ? (
|
||||||
<div className="mt-3 space-y-2 text-sm leading-7 text-[var(--muted)]">
|
<div style={{ marginTop: 9, color: "var(--success)", fontSize: 12, lineHeight: 1.6 }}>
|
||||||
<p className="whitespace-pre-line">{notice}</p>
|
<p className="whitespace-pre-line">{notice}</p>
|
||||||
{createdMessagePublicId ? (
|
{createdMessagePublicId ? (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -1,39 +1,29 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import { ChevronLeft, Home, MessageCircle, Smartphone, UserRound } from "lucide-react";
|
||||||
Home,
|
|
||||||
MessageCircle,
|
|
||||||
Smartphone,
|
|
||||||
Activity,
|
|
||||||
ScanLine,
|
|
||||||
Settings,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
{ href: "/", label: "首页", icon: Home },
|
{ href: "/", label: "概览", icon: Home },
|
||||||
{ href: "/messages", label: "留言", icon: MessageCircle },
|
{ href: "/messages", label: "留言", icon: MessageCircle },
|
||||||
{ href: "/devices", label: "设备", icon: Smartphone },
|
{ href: "/devices", label: "设备", icon: Smartphone },
|
||||||
{ href: "/activity", label: "动态", icon: Activity },
|
{ href: "/settings", label: "我的", icon: UserRound },
|
||||||
{ href: "/scan", label: "扫码", icon: ScanLine },
|
|
||||||
{ href: "/settings", label: "设置", icon: Settings },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function isActivePath(currentPath: string, href: string) {
|
function isActivePath(currentPath: string, href: string) {
|
||||||
if (href === "/") {
|
if (href === "/") return currentPath === "/";
|
||||||
return currentPath === "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
type PanelShellProps = {
|
type PanelShellProps = {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description?: string;
|
||||||
caregiverLabel: string;
|
caregiverLabel: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
eyebrow?: string;
|
backHref?: string;
|
||||||
|
showTabs?: boolean;
|
||||||
|
unreadCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PanelShell({
|
export function PanelShell({
|
||||||
@ -43,71 +33,55 @@ export function PanelShell({
|
|||||||
caregiverLabel,
|
caregiverLabel,
|
||||||
children,
|
children,
|
||||||
actions,
|
actions,
|
||||||
eyebrow = "家人连线",
|
backHref,
|
||||||
|
showTabs = !backHref,
|
||||||
|
unreadCount = 0,
|
||||||
}: PanelShellProps) {
|
}: PanelShellProps) {
|
||||||
return (
|
const initial = caregiverLabel.trim().slice(-1) || "安";
|
||||||
<main className="relative overflow-hidden px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-72 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.8),transparent_70%)]" />
|
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
|
||||||
<header className="paper-panel relative overflow-hidden rounded-[36px] border border-white/70 px-6 py-7 shadow-[0_35px_90px_rgba(105,68,41,0.13)] sm:px-8 lg:px-10 lg:py-8">
|
|
||||||
<div className="absolute -right-12 top-[-50px] h-44 w-44 rounded-full bg-[rgba(53,84,141,0.14)] blur-2xl" />
|
|
||||||
<div className="absolute left-10 top-10 h-24 w-24 rounded-full border border-[rgba(118,132,94,0.18)] z-0 pointer-events-none" />
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
|
|
||||||
<div className="max-w-4xl">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{navigationItems.map((item) => {
|
|
||||||
const active = isActivePath(currentPath, item.href);
|
|
||||||
const Icon = item.icon;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<main className="app-frame">
|
||||||
key={item.href}
|
<div className={`app-content app-stack${backHref ? " detail" : ""}`}>
|
||||||
href={item.href}
|
{backHref ? (
|
||||||
className={`inline-flex h-11 items-center justify-center gap-2 rounded-full px-4 text-sm font-semibold transition ${
|
<header className="detail-header">
|
||||||
active
|
<Link href={backHref} className="back-button" aria-label="返回">
|
||||||
? "bg-[var(--ink)] text-white shadow-[0_12px_30px_rgba(36,27,20,0.18)]"
|
<ChevronLeft size={20} strokeWidth={2} />
|
||||||
: "border border-[var(--line)] bg-white/82 text-[var(--ink)] hover:bg-[var(--paper-soft)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={16} />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
<h1>{title}</h1>
|
||||||
})}
|
{actions}
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-6 text-sm font-semibold tracking-[0.24em] text-[var(--copper)] uppercase">
|
|
||||||
{eyebrow}
|
|
||||||
</p>
|
|
||||||
<h1 className="mt-4 max-w-3xl font-[family-name:var(--font-display)] text-4xl leading-[1.15] text-[var(--ink)] sm:text-5xl">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-5 max-w-2xl text-base leading-8 text-[var(--muted)] sm:text-lg">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
|
||||||
<span className="rounded-full border border-[var(--line)] bg-white/76 px-4 py-2">
|
|
||||||
账号:{caregiverLabel}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{actions ? <div className="w-full max-w-md xl:pt-2">{actions}</div> : null}
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
) : (
|
||||||
|
<header className="page-heading">
|
||||||
|
<div>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{description ? <p>{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ?? (
|
||||||
|
<Link href="/settings" className="avatar" aria-label="我的">
|
||||||
|
{initial}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showTabs ? (
|
||||||
|
<nav className="bottom-nav" aria-label="主导航">
|
||||||
|
{navigationItems.map((item) => {
|
||||||
|
const active = isActivePath(currentPath, item.href);
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link key={item.href} href={item.href} className={`nav-item${active ? " active" : ""}`}>
|
||||||
|
<Icon size={22} strokeWidth={1.8} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{item.href === "/messages" && unreadCount > 0 ? <span className="nav-badge">{unreadCount}</span> : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -12,6 +12,8 @@ const eslintConfig = defineConfig([
|
|||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
// Design reference exported by the prototyping tool; not application source.
|
||||||
|
"安智伴家属端界面优化/**",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
turbopack: {
|
||||||
|
root: process.cwd(),
|
||||||
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "16.2.4",
|
"next": "^16.2.10",
|
||||||
"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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user