Compare commits

..
23 Commits
Author SHA1 Message Date
feie9454 bbfc055c21 优化ui 2026-07-19 19:45:32 +08:00
feie9454 b68bfc6287 修改图标 2026-07-18 19:59:39 +08:00
feie9454 747dcd359a 家庭守护圈:多人加入、角色权限、24 小时邀请链接、旧设备绑定自动兼容。
家庭动态:老人/家属发言、三种可见范围,家庭内部内容不会播给老人。
共同照护:家庭备忘录、任务创建、认领、完成与交接记录。
用药闭环:计划、未来 7 天提醒实例、确认服用、延后、异常反馈。
风险闭环:红橙黄蓝分级、30 分钟去重、推送、认领、处理与误报记录。
报平安:家属发起、老人语音回应、状态同步。
Web 导航升级为守护、家庭、照护、陪伴、我的。
设备接口已升级为设备令牌认证,UUID 不再单独承担认证。
2026-07-18 19:59:20 +08:00
feie9454 b7579c5947 修复切换query进度条不消失 2026-07-11 23:40:34 +08:00
feie9454 386a28709f 全新UI语言 2026-07-11 23:37:48 +08:00
feie9454 4dde861c99 修改pwa主题色 2026-04-18 12:12:16 +08:00
feie9454 23f8d42818 更新 readme 2026-04-18 11:42:14 +08:00
feie9454 be1b44190c 修复 useSearchParams 编译问题 2026-04-18 11:07:10 +08:00
feie9454 5cbb7217f0 优化加载动画 2026-04-18 11:03:34 +08:00
feie9454 70d42e6792 新增设备别名管理功能,支持设备别名的保存与解绑 2026-04-17 15:08:39 +08:00
feie9454 71760cbb1f 修复ios topic报错 2026-04-17 14:48:24 +08:00
feie9454 3134aab4dc push增加详细消息 2026-04-17 14:44:27 +08:00
feie9454 6a65d956a8 新增设置页 2026-04-17 14:26:28 +08:00
feie9454 2cf1fae21d 优化 pwa 文案 2026-04-17 14:06:53 +08:00
feie9454 a255b23be3 修复pwa检测 2026-04-17 13:55:43 +08:00
feie9454 3469f53d79 修改title 2026-04-17 13:40:35 +08:00
feie9454 4e68054984 修改icon 2026-04-17 13:21:33 +08:00
feie9454 f2c56605ac 修复扫码边框 2026-04-17 13:07:34 +08:00
feie9454 dee056a76a 修复退出登录路径 2026-04-17 13:04:17 +08:00
feie9454 d3b03cecf2 添加账号系统 2026-04-17 12:58:38 +08:00
feie9454 032e207888 优化文案 2026-04-17 12:38:59 +08:00
feie9454 0da5936ce0 优化路径,添加消息推送 2026-04-17 12:14:57 +08:00
feie9454 b8a842367b 修复路径跳转 2026-04-17 11:47:17 +08:00
85 changed files with 13370 additions and 1345 deletions
+52
View File
@@ -0,0 +1,52 @@
# 安智伴家属端设计规范 v2
本规范以 2026-07-19 最新移动版原型为唯一视觉基准,设计目标是「安静、可信、清晰」。保留暖纸底色与铜橙主色,优先呈现长辈状态、家庭协作和可执行动作。
## 画布与布局
- 移动端内容宽度:`100%`,最大 `430px`;原型画板为 `430 × 912px`。
- 页面背景:`#F5F4F2`;桌面居中画布:`#E7E4DF`。
- 页面水平留白:`18px`;主模块垂直间距:`18px`。
- 主页面顶部留白 `22px`,底部为五项固定导航,内容预留 `110px`。
- 桌面端画板圆角 `30px`,移动端贴合视口,不保留外部装饰。
## 颜色
| 语义 | 值 | 用途 |
| --- | --- | --- |
| 主文字 | `#211E1A` | 标题、正文、深色按钮 |
| 次文字 | `#3D3833` | 列表正文 |
| 辅助文字 | `#8B857C` | 时间、状态、说明 |
| 弱辅助文字 | `#B5AFA6` | 次级时间、图标 |
| 品牌强调 | `#C05A1E` | 主按钮、未读、当前导航 |
| 成功/在线 | `#3F9D63` | 在线状态、成功反馈 |
| 危险操作 | `#B4482C` | 退出、风险状态 |
| 橄榄辅助 | `#65765A` | 设备、陪伴类图标 |
| 分隔线 | `rgba(33,30,26,.08)` | 卡片与列表分隔 |
所有值均定义在 `app/globals.css`,页面不要自行引入近似色。
## 字体与信息层级
- 字体栈:系统无衬线字体,中文优先苹方、冬青黑体、微软雅黑。
- 页面标题:`21px / 700`;详情标题:`16px / 700`。
- 区块标题:`15px / 700`;列表正文:`13–14px / 1.6`。
- 元信息:`11.5–12.5px`;数字指标:`21px / 700`。
## 组件规则
- 卡片:白色背景、`14px` 圆角、`1px` 弱边框,不使用重阴影。
- 列表:左右 `16px`、上下 `13px`,内部使用弱分隔线。
- 主按钮与输入框:统一 `44px` 高、`10–11px` 圆角。
- 小按钮:`32px` 高、`8px` 圆角。
- 表单字段必须有标签;下拉框隐藏系统箭头并使用统一箭头。
- 标签只用于重要状态,使用浅品牌色或浅成功色背景。
- 底部导航固定五项:守护、家庭、照护、陪伴、我的。
- 照护页使用浅灰分段控件,选中项为白底并带极轻阴影。
## 交互与可访问性
- 页面跳转使用 Next.js `Link`,保留客户端导航与预取能力。
- 主要点击区域不小于 `40px`,小型操作不小于 `32px`。
- 状态不能只依赖颜色,在线、未读、重要均同时提供文字或数量。
- 动效仅用于在线呼吸点、导航进度和必要反馈,并尊重系统减少动态设置。
+525 -22
View File
@@ -1,36 +1,539 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # 安智伴 · 家人连线台(Web 监护端)
## Getting Started > 本项目与 [dh_aigc_android](https://github.com/user/dh_aigc_android)(Android AI 数字人陪护终端)配合使用,共同构成完整的"AI 数字人 + 家庭远程关怀"解决方案。
First, run the development server: ---
```bash ## 目录
npm run dev
# or - [需求分析](#需求分析)
yarn dev - [设计思路](#设计思路)
# or - [技术架构](#技术架构)
pnpm dev - [数据库设计](#数据库设计)
# or - [页面与功能](#页面与功能)
bun dev - [API 接口](#api-接口)
- [全链路数据流](#全链路数据流)
- [推送通知系统](#推送通知系统)
- [身份验证](#身份验证)
- [开发与部署](#开发与部署)
---
## 需求分析
### 背景
配合 Android 端 AI 数字人陪护应用,家属需要一个便捷的远程监护面板来:
- 了解老人是否在使用设备、与 AI 聊了什么
- 主动发送关怀留言,由数字人口播转达
- 接收老人通过 AI 口述的回复留言
- 获得实时推送通知,不遗漏重要消息
### 核心需求
| 需求维度 | 描述 |
|---------|------|
| **双向留言** | 家属发送留言 → 数字人口播给老人;老人口述 → AI 转录 → 家属在此查看 |
| **使用监测** | 查看老人设备的使用频率、AI 对话次数、最后在线时间 |
| **设备绑定** | 支持扫码或手动输入设备码绑定老人设备,一个家属可绑定多台设备 |
| **推送提醒** | 收到新留言时浏览器推送通知(PWA + Web Push) |
| **多端登录** | 同一家属账号可在多个浏览器/设备登录 |
| **面向家属的友好界面** | 暖色调、生活化文案,避免技术化的配置面板 |
### 用户角色
| 角色 | 使用场景 |
|------|---------|
| **家属**(本 Web 端) | 登录后查看老人动态、发送留言、管理设备绑定、接收推送 |
| **老人**(Android 端) | 不直接使用本系统,通过 AI 数字人间接交互 |
| **设备**(Android 端自动上报) | 自动注册、上报使用事件和对话数据 |
---
## 设计思路
### 1. 以留言为核心的关怀模式
不同于即时通讯工具,本系统采用"留言板"模式:
- 家属写留言 → 等老人下次与 AI 对话时口播转达
- 老人对 AI 说"帮我给儿子捎个话" → AI 通过工具调用存入数据库 → 家属收到推送
这种异步模式契合老人的使用习惯,无需老人学习操作手机。
### 2. 设备即桥梁
- Android 设备是唯一的数据桥梁,通过 UUID 标识
- 家属通过扫码或输入设备码绑定设备
- 所有消息和事件都关联到设备维度
### 3. PWA 优先
- 支持"安装到桌面",像原生 App 一样使用
- Service Worker 处理推送通知
- 即使浏览器关闭也能收到消息提醒
### 4. 温暖的视觉语言
- 暖色系纸质质感(`--paper: #f4ece0`、`--copper: #c76733`、`--olive: #76845e`)
- 生活化文案("写好并送出"而非"提交")
- 面向不一定技术精通的家属用户
---
## 技术架构
### 技术栈
| 层级 | 技术 |
|------|------|
| **框架** | Next.js 16 (App Router, Server Components) |
| **前端** | React 19 + TypeScript 5 |
| **样式** | Tailwind CSS 4 + CSS 变量暖色系统 |
| **ORM** | Prisma 7 + PrismaPg 适配器 |
| **数据库** | PostgreSQL |
| **推送** | Web Push API + VAPID 密钥 |
| **认证** | Session-based (HttpOnly Cookie + bcryptjs) |
| **PWA** | Service Worker + Web App Manifest |
| **扫码** | qr-scanner (浏览器端摄像头扫码) |
### 系统架构图
```
┌──────────────────────────────────────────────────────────────────┐
│ 家属浏览器 (PWA) │
│ │
│ ┌─────────────────┐ ┌─────────────┐ ┌────────────────────┐ │
│ │ 设备管理 │ │ 留言板 │ │ 陪伴动态 │ │
│ │ (扫码绑定/列表) │ │ (双向消息) │ │ (使用/对话/工具) │ │
│ └────────┬────────┘ └──────┬──────┘ └─────────┬──────────┘ │
│ │ │ │ │
│ ┌────────▼──────────────────▼────────────────────▼──────────┐ │
│ │ Service Worker (Web Push) │ │
│ └───────────────────────────┬───────────────────────────────┘ │
└──────────────────────────────┼───────────────────────────────────┘
│ HTTPS
▼
┌──────────────────────────────────────────────────────────────────┐
│ Next.js Server │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ API Routes │ │
│ │ /api/account/* 认证 (登录/注册/登出) │ │
│ │ /api/family/* 家属操作 (发留言/绑定/别名/解绑) │ │
│ │ /api/device/* 设备上报 (注册/消息/事件/对话/工具) │ │
│ │ /api/push/* 推送管理 (订阅/退订/测试) │ │
│ └────────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────▼───────────────────────────────┐ │
│ │ Prisma ORM │ │
│ └────────────────────────────┬───────────────────────────────┘ │
└───────────────────────────────┼──────────────────────────────────┘
│
┌───────────▼───────────┐
│ PostgreSQL │
│ │
│ CaregiverAccount │
│ CaregiverSession │
│ ElderDevice │
│ DeviceBinding │
│ FamilyMessage │
│ ConversationTurn │
│ UsageEvent │
│ ToolCallLog │
│ PushSubscription │
└───────────────────────┘
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. ### 与 Android 端的通信
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ```
┌──────────────────┐ ┌──────────────────┐
│ Android 设备端 │ │ Next.js 云端 │
│ │ │ │
│ FamilyBridge │ ──────→ │ /api/device/* │
│ Service │ HTTPS │ │
│ │ │ • register │
│ • 设备注册 │ ──────→ │ • messages │
│ • 拉取留言 │ ←────── │ • messages/read │
│ • 标记已读 │ ──────→ │ • usage │
│ • 上报事件 │ ──────→ │ • conversations │
│ • 上传对话 │ ──────→ │ • tool-call │
│ • 工具调用记录 │ ──────→ │ │
└──────────────────┘ └──────────────────┘
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ---
## Learn More ## 数据库设计
To learn more about Next.js, take a look at the following resources: ### ER 关系
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ```
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. CaregiverAccount ──1:N──→ CaregiverSession (多设备登录)
CaregiverAccount ──1:N──→ DeviceBinding (绑定多台设备)
CaregiverAccount ──1:N──→ FamilyMessage (发出的留言)
CaregiverAccount ──1:N──→ PushSubscription (推送端点)
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ElderDevice ──1:N──→ DeviceBinding (被多个家属绑定)
ElderDevice ──1:N──→ FamilyMessage (关联的留言)
ElderDevice ──1:N──→ ConversationTurn (对话记录)
ElderDevice ──1:N──→ UsageEvent (使用事件)
ElderDevice ──1:N──→ ToolCallLog (工具调用日志)
```
## Deploy on Vercel ### 核心数据模型
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. #### CaregiverAccount(家属账号)
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. | 字段 | 类型 | 说明 |
|------|------|------|
| id | Int (PK) | 自增主键 |
| username | String (unique) | 账户名,3-24 位 |
| passwordHash | String | bcrypt 密码哈希 |
| nickname | String? | 昵称 |
#### ElderDevice(老人设备)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int (PK) | 自增主键 |
| deviceUuid | String (unique) | UUID 格式设备号 |
| displayName | String? | 设备显示名 |
| appVersion | String? | 应用版本号 |
| lastSeenAt | DateTime | 最后在线时间 |
#### DeviceBinding(设备绑定)
| 字段 | 类型 | 说明 |
|------|------|------|
| caregiverId + elderDeviceId | 复合唯一键 | 多对多桥接 |
| alias | String? | 自定义别名(≤24 字) |
#### FamilyMessage(双向留言)
| 字段 | 类型 | 说明 |
|------|------|------|
| publicId | Int (auto-increment) | 公开 ID,用于 URL |
| content | String | 留言内容 |
| direction | Enum | `ELDER_TO_FAMILY` / `FAMILY_TO_ELDER` |
| importance | Enum | `LOW` / `NORMAL` / `HIGH` / `URGENT` |
| status | Enum | `PENDING` / `DELIVERED` / `READ` |
#### UsageEvent(使用事件)
事件类型:`APP_OPEN` · `SETTINGS_OPENED` · `AI_SESSION_STARTED` · `AI_SESSION_ENDED` · `CAMERA_ENABLED` · `CAMERA_DISABLED` · `TOOL_CALLED`
#### ConversationTurn(对话轮次)
角色:`USER`(老人语音)· `ASSISTANT`(AI 回复)· `TOOL`(工具调用)
#### ToolCallLog(工具调用日志)
记录 AI 对话中触发的工具调用,包括工具名、参数 JSON、执行结果、状态。
### 迁移历史
| 版本 | 时间 | 内容 |
|------|------|------|
| v1 | 2026-04-17 01:50 | 初始表结构 + 枚举类型 |
| v2 | 2026-04-17 04:09 | 添加 publicId + PushSubscription 表 |
| v3 | 2026-04-17 04:51 | 账号认证系统 + 多设备会话 |
| v4 | 2026-04-17 07:00 | 设备绑定别名功能 |
---
## 页面与功能
### 页面结构
```
/login → 登录 / 注册
/ → 首页概览 (KPI 卡片 + 导航)
/devices → 设备列表 (绑定新设备 + 快速统计)
/devices/[uuid] → 设备详情 (发送留言 + 收到留言 + 活动记录)
/messages → 留言板 (双列:老人留言 / 我的问候)
/messages/[id] → 留言详情
/activity → 陪伴动态 (使用记录 + 聊天记录 + 智能服务)
/scan → 扫码绑定 (QR Scanner)
/settings → 设置 (PWA 安装 + 推送管理)
/bind → 绑定结果页
```
### 首页 `/`
- 4 个 KPI 卡片:已绑设备数、新留言数、待查看数、对话总数
- 4 个功能导航卡片:留言板、设备管理、陪伴动态、设置
- 无设备时显示引导流程
- 最近消息预览
### 设备管理 `/devices`
- 手动粘贴设备码绑定
- 设备卡片:在线状态、留言数、对话数、智能服务调用数
- 最新动态预览
### 设备详情 `/devices/[uuid]`
- KPI 指标(新留言、待查看、最后在线、绑定链接)
- 设备管理(别名修改、解绑)
- 发送留言表单(4 个优先级)
- 最近收到的留言列表
- 最近活动记录
- 已发送的问候
- 最近对话片段
### 留言板 `/messages`
- 双列分组:左侧"长辈的留言"(含未读计数)、右侧"我发出的问候"(含待查看计数)
- 每条显示:公开 ID、方向标签、设备名、优先级、状态、时间戳
### 陪伴动态 `/activity`
- 三列展示:使用记录、聊天记录、智能服务
- 趋势可视化图表
### 扫码绑定 `/scan`
- 浏览器摄像头 QR 扫描
- 自动摄像头授权检测
- 失败降级提示手动输入
---
## API 接口
### 账号管理 `/api/account/`
| 路由 | 方法 | 功能 |
|------|------|------|
| `/login` | POST | 登录(username + password → 设置 Session Cookie) |
| `/register` | POST | 注册(创建账号 + 自动登录) |
| `/logout` | POST | 登出(清除 Cookie + 删除 Session) |
### 家属操作 `/api/family/`
| 路由 | 方法 | 功能 |
|------|------|------|
| `/messages` | POST | 发送留言给老人(创建 FamilyMessage + 触发 Web Push) |
| `/device-binding` | PATCH | 修改设备别名 |
| `/device-binding` | DELETE | 解绑设备 |
| `/bind` | POST | 绑定新设备 |
| `/session` | GET | 会话验证 + 重定向 |
### 设备上报 `/api/device/`
| 路由 | 方法 | 功能 | 认证方式 |
|------|------|------|---------|
| `/register` | POST | 设备注册/更新 | deviceUuid 验证 |
| `/messages` | GET | 拉取待发送消息 | deviceUuid 查询参数 |
| `/messages/read` | POST | 标记消息已读 | deviceUuid 验证 |
| `/conversations` | POST | 记录对话内容 | deviceUuid 验证 |
| `/usage` | POST | 上报使用事件 | deviceUuid 验证 |
| `/tool-call` | POST | 记录工具调用 | deviceUuid 验证 |
### 推送管理 `/api/push/`
| 路由 | 方法 | 功能 |
|------|------|------|
| `/subscribe` | POST | 订阅推送通知(需登录) |
| `/unsubscribe` | POST | 取消订阅 |
| `/test` | POST | 发送测试推送 |
---
## 全链路数据流
### 1. 设备绑定流程
```
老人设备首次启动 → POST /api/device/register → 获得 deviceUuid + bindUrl
→ 设备屏幕展示二维码(编码 bindUrl)
→ 家属打开 /scan 页面 → 浏览器摄像头扫码
→ 解析出 /bind?deviceUuid=xxx
→ 未登录 → 重定向 /login?redirectTo=/bind?deviceUuid=xxx
→ 已登录 → 调用 POST /api/family/bind
→ 检查/创建 ElderDevice → 创建 DeviceBinding → 迁移遗留数据
→ 绑定成功页
```
### 2. 家属发送留言 → 老人收到
```
家属在 /devices/[uuid] 填写留言 → POST /api/family/messages
→ 创建 FamilyMessage (FAMILY_TO_ELDER, PENDING)
→ 触发 sendIncomingMessagePush() → 向所有订阅端点发送 Web Push
→ 家属收到浏览器通知确认
→ (稍后) 老人设备启动 AI 对话
→ FamilyBridgeService → GET /api/device/messages
→ 返回未读消息 (summaryText + messages[])
→ 注入 AI 系统 Prompt → AI 在第一轮回复中主动口播转达
→ 设备端 → POST /api/device/messages/read → 标记已读
```
### 3. 老人口述留言 → 家属收到
```
老人对 AI 说 "帮我给女儿说一声我今天去公园了"
→ Qwen 识别意图 → 触发 leave_message_for_family 工具
→ FamilyBridgeService → POST /api/device/conversations
→ 创建 FamilyMessage (ELDER_TO_FAMILY)
→ 触发 Web Push → 家属浏览器通知
→ 家属打开 /messages 查看
```
### 4. 使用监测
```
设备事件 (APP_OPEN / AI_SESSION_STARTED / ...)
→ POST /api/device/usage → 存入 UsageEvent
→ 家属访问 /activity → Server Component 查询最近事件
→ 渲染使用趋势图 + 对话记录 + 工具调用日志
```
---
## 推送通知系统
### 架构
```
家属浏览器
├── 注册 Service Worker (/sw.js)
├── PushManager.subscribe(VAPID 公钥) → 获取 PushSubscription
├── POST /api/push/subscribe → 存入数据库
└── 收到推送 → showNotification() → 点击 → 打开对应页面
服务端 (新消息触发时)
├── 查询目标设备所有已绑定家属的 PushSubscription
├── 循环调用 webpush.sendNotification()
└── 处理 410 Gone → 清理失效端点
```
### Service Worker 功能
- **push 事件**:解析 JSON → 调用 `showNotification()`,显示标题、内容、图标
- **notificationclick 事件**:打开或聚焦到目标 URL,支持已打开窗口的导航
- **去重**:使用 `tag: "digital-human-message"` 合并同类通知
---
## 身份验证
### 家属端(Session-based)
| 项目 | 说明 |
|------|------|
| Cookie 名 | `dh-caregiver-session` |
| 属性 | HttpOnly + Secure (生产) + SameSite=Lax |
| 有效期 | 365 天 |
| 密码哈希 | bcryptjs |
| 多设备 | 每次登录创建独立 Session,支持多端在线 |
### 设备端(UUID 隐式验证)
设备通过 `deviceUuid` 参数标识身份,依赖 UUID 的不可猜测性。无需额外认证令牌。
---
## 开发与部署
### 环境变量
```env
DATABASE_URL=postgresql://... # PostgreSQL 连接串
NEXT_PUBLIC_VAPID_PUBLIC_KEY=... # Web Push 公钥(客户端)
VAPID_PRIVATE_KEY=... # Web Push 私钥(服务端)
VAPID_SUBJECT=mailto:admin@example.com # 推送发送者标识
```
### 本地开发
```bash
# 安装依赖
npm install
# 数据库迁移
npx prisma migrate dev
# 启动开发服务器
npm run dev
```
访问 [http://localhost:3000](http://localhost:3000) 。
### 数据库管理
```bash
# 查看数据库
npx prisma studio
# 生成 Prisma Client
npx prisma generate
# 创建新迁移
npx prisma migrate dev --name <migration-name>
```
### 生产部署
```bash
# 构建
npm run build
# 启动
npm start
```
支持部署到 Vercel、Docker 或任何 Node.js 环境。
### 项目结构
```
digital-human-monitor-v2/
├── app/ # Next.js App Router
│ ├── layout.tsx # 根布局 (认证状态 + 导航)
│ ├── page.tsx # 首页概览
│ ├── globals.css # 全局样式 (暖色系变量)
│ ├── manifest.ts # PWA Manifest
│ ├── api/ # API 路由
│ │ ├── account/ # 登录/注册/登出
│ │ ├── device/ # 设备上报接口
│ │ ├── family/ # 家属操作接口
│ │ └── push/ # 推送管理
│ ├── activity/ # 陪伴动态页
│ ├── bind/ # 绑定结果页
│ ├── devices/ # 设备管理
│ │ └── [deviceUuid]/ # 设备详情
│ ├── login/ # 登录/注册页
│ ├── messages/ # 留言板
│ │ └── [messageId]/ # 留言详情
│ ├── scan/ # 扫码绑定
│ └── settings/ # 设置页
├── components/ # 可复用 UI 组件
├── lib/ # 业务逻辑库
│ ├── prisma.ts # Prisma 客户端单例
│ ├── session.ts # Session 管理
│ ├── page-auth.ts # 页面级认证守卫
│ ├── push.ts # Web Push 发送逻辑
│ ├── caregiver-panel.ts # 家属面板数据聚合
│ ├── monitor-data.ts # 监测数据查询
│ └── panel-format.ts # 面板格式化工具
├── prisma/
│ ├── schema.prisma # 数据库 Schema
│ └── migrations/ # 迁移文件
├── public/
│ ├── sw.js # Service Worker
│ └── pwa/ # PWA 图标资源
└── scripts/
└── generate-icons.mjs # 图标生成脚本
```
---
## 关联项目
| 项目 | 角色 | 仓库 |
|------|------|------|
| **digital-human-monitor-v2**(本项目) | 家属端:远程监护 Web 面板 + 消息管理 + 推送通知 | 当前仓库 |
| **dh_aigc_android** | 老人端:AI 数字人陪护 + 设备行为上报 | [dh_aigc_android](../dh_aigc_android/) |
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
import { PanelShell } from "@/components/panel-shell";
import { getCaregiverActivity } from "@/lib/caregiver-panel";
import { formatDateTime, getConversationRoleLabel, getDeviceName, getUsageLabel, truncateText } from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export const dynamic = "force-dynamic";
export default async function ActivityPage({ searchParams }: PageProps<"/activity">) {
const caregiver = await requireCaregiverSession("/activity");
const { usageEvents, conversationTurns, toolCalls } = await getCaregiverActivity(caregiver.id);
const rawTab = (await searchParams).tab;
const tab = rawTab === "chat" || rawTab === "tool" ? rawTab : "usage";
const rows = tab === "usage"
? 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 (
<PanelShell currentPath="/activity" title="陪伴动态" caregiverLabel={getCaregiverDisplayName(caregiver)} backHref="/">
<div className="segment">
<Link href="/activity?tab=usage" className={tab === "usage" ? "active" : ""}>使用</Link>
<Link href="/activity?tab=chat" className={tab === "chat" ? "active" : ""}>对话</Link>
<Link href="/activity?tab=tool" className={tab === "tool" ? "active" : ""}>智能服务</Link>
</div>
<section className="card">
{rows.length ? rows.map((row) => (
<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>
<p className="timeline-body">{row.body}</p>
</article>
)) : <div className="empty">这个分类暂时还没有动态。</div>}
</section>
<p className="muted-note">「智能服务」记录数字人助理自主调用的工具,包括照护知识查询与双向留言等。</p>
</PanelShell>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import {
loginCaregiverAccount,
sanitizeCaregiverRedirectPath,
} from "@/lib/session";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
| {
username?: string;
password?: string;
redirectTo?: string;
}
| null;
if (!body?.username || typeof body.username !== "string") {
return NextResponse.json({ error: "请先输入用户名。" }, { status: 400 });
}
if (typeof body.password !== "string" || body.password.length === 0) {
return NextResponse.json({ error: "请先输入密码。" }, { status: 400 });
}
try {
await loginCaregiverAccount({
username: body.username,
password: body.password,
userAgent: request.headers.get("user-agent"),
});
return NextResponse.json({
ok: true,
redirectTo: sanitizeCaregiverRedirectPath(body.redirectTo),
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "登录失败,请稍后再试。",
},
{ status: 400 },
);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { clearCurrentCaregiverSession } from "@/lib/session";
export async function POST() {
await clearCurrentCaregiverSession();
return new NextResponse(null, {
status: 303,
headers: {
Location: "/login",
},
});
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import {
registerCaregiverAccount,
sanitizeCaregiverRedirectPath,
} from "@/lib/session";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
| {
username?: string;
password?: string;
redirectTo?: string;
}
| null;
if (!body?.username || typeof body.username !== "string") {
return NextResponse.json({ error: "请先输入用户名。" }, { status: 400 });
}
if (typeof body.password !== "string" || body.password.length === 0) {
return NextResponse.json({ error: "请先输入密码。" }, { status: 400 });
}
try {
await registerCaregiverAccount({
username: body.username,
password: body.password,
userAgent: request.headers.get("user-agent"),
});
return NextResponse.json({
ok: true,
redirectTo: sanitizeCaregiverRedirectPath(body.redirectTo),
});
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "创建账号失败,请稍后再试。",
},
{ status: 400 },
);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { updateAlert } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function PATCH(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const body = await request.json().catch(() => ({})) as Record<string, unknown>;
const alert = await updateAlert(caregiver.id, String(body.alertId || ""), String(body.action || ""), body.note);
return NextResponse.json({ ok: true, alert });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "更新提醒失败。" }, { status: 400 }); }
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { createCareTask, updateCareTask } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const task = await createCareTask(caregiver.id, await request.json().catch(() => ({})));
return NextResponse.json({ ok: true, task });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "创建任务失败。" }, { status: 400 }); }
}
export async function PATCH(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const body = await request.json().catch(() => ({})) as Record<string, unknown>;
const task = await updateCareTask(caregiver.id, String(body.taskId || ""), String(body.action || ""), body.note);
return NextResponse.json({ ok: true, task });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "更新任务失败。" }, { status: 400 }); }
}
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { createCheckIn } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const checkIn = await createCheckIn(caregiver.id, await request.json().catch(() => ({})));
return NextResponse.json({ ok: true, checkIn });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "发起报平安失败。" }, { status: 400 }); }
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { requireDeviceAuth } from "@/lib/device-auth";
import { getDeviceAgenda } from "@/lib/family-guard";
export async function GET(request: Request) {
const deviceUuid = new URL(request.url).searchParams.get("deviceUuid") || "";
try {
await requireDeviceAuth(request, deviceUuid);
const agenda = await getDeviceAgenda(deviceUuid);
return NextResponse.json({
circleName: agenda.circle?.name || null,
posts: agenda.posts.map((post) => ({ id: post.id, content: post.content, authorName: post.author?.nickname || post.author?.username || "家里人", createdAt: post.createdAt })),
memos: agenda.memos.map((memo) => ({ id: memo.id, title: memo.title, content: memo.content, priority: memo.priority })),
doses: agenda.doses.map((dose) => ({ id: dose.id, scheduledFor: dose.scheduledFor, status: dose.status, name: dose.medicationPlan.name, dosage: dose.medicationPlan.dosage, instructions: dose.medicationPlan.instructions })),
checkIns: agenda.checkIns,
});
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "读取今日照护事项失败。" }, { status: 401 }); }
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { requireDeviceAuth } from "@/lib/device-auth";
import { createSafetyAlertForDevice } from "@/lib/family-guard";
import { sendGuardAlertPush } from "@/lib/push";
export async function POST(request: Request) {
const body = await request.json().catch(() => ({})) as Record<string, unknown>;
const deviceUuid = typeof body.deviceUuid === "string" ? body.deviceUuid : "";
try {
await requireDeviceAuth(request, deviceUuid);
const result = await createSafetyAlertForDevice(deviceUuid, body);
if (!result.deduplicated) void sendGuardAlertPush(result.alert.id).catch(() => undefined);
return NextResponse.json({ ok: true, alertId: result.alert.id, deduplicated: result.deduplicated });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "创建风险提醒失败。" }, { status: 400 }); }
}
+1
View File
@@ -0,0 +1 @@
export { GET } from "@/app/api/device/agenda/route";
+2
View File
@@ -2,6 +2,7 @@ import { ConversationRole } from "@prisma/client";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { recordConversationTurns } from "@/lib/monitor-data"; import { recordConversationTurns } from "@/lib/monitor-data";
import { requireDeviceAuth } from "@/lib/device-auth";
function toConversationRole(rawRole: unknown) { function toConversationRole(rawRole: unknown) {
switch (rawRole) { switch (rawRole) {
@@ -55,6 +56,7 @@ export async function POST(request: Request) {
} }
try { try {
await requireDeviceAuth(request, body.deviceUuid);
const result = await recordConversationTurns({ const result = await recordConversationTurns({
deviceUuid: body.deviceUuid, deviceUuid: body.deviceUuid,
sessionId: typeof body.sessionId === "string" ? body.sessionId : undefined, sessionId: typeof body.sessionId === "string" ? body.sessionId : undefined,
+28
View File
@@ -0,0 +1,28 @@
import { CheckInStatus, MedicationDoseStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { requireDeviceAuth } from "@/lib/device-auth";
import { respondCheckIn, updateMedicationDose } from "@/lib/family-guard";
export async function POST(request: Request) {
const body = await request.json().catch(() => ({})) as Record<string, unknown>;
const deviceUuid = typeof body.deviceUuid === "string" ? body.deviceUuid : "";
try {
await requireDeviceAuth(request, deviceUuid);
if (body.type === "MEDICATION") {
const allowed = Object.values(MedicationDoseStatus);
const status = allowed.includes(body.status as MedicationDoseStatus) ? body.status as MedicationDoseStatus : MedicationDoseStatus.NEEDS_FAMILY_CONFIRMATION;
const dose = await updateMedicationDose(deviceUuid, String(body.doseId || ""), status, body.note, Number(body.snoozeMinutes || 10));
return NextResponse.json({ ok: true, dose });
}
if (body.type === "CHECK_IN") {
const status = body.status === CheckInStatus.SAFE
? CheckInStatus.SAFE
: body.status === CheckInStatus.DECLINED
? CheckInStatus.DECLINED
: CheckInStatus.NEEDS_CONTACT;
const checkIn = await respondCheckIn(deviceUuid, String(body.checkInId || ""), status, body.responseText);
return NextResponse.json({ ok: true, checkIn });
}
throw new Error("不支持的设备事件类型。");
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "保存照护反馈失败。" }, { status: 400 }); }
}
+2
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { markFamilyMessagesAsRead } from "@/lib/monitor-data"; import { markFamilyMessagesAsRead } from "@/lib/monitor-data";
import { requireDeviceAuth } from "@/lib/device-auth";
export async function POST(request: Request) { export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as const body = (await request.json().catch(() => null)) as
@@ -24,6 +25,7 @@ export async function POST(request: Request) {
: undefined; : undefined;
try { try {
await requireDeviceAuth(request, body.deviceUuid);
await markFamilyMessagesAsRead(body.deviceUuid, messageIds); await markFamilyMessagesAsRead(body.deviceUuid, messageIds);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
+2
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { listFamilyMessagesForDevice } from "@/lib/monitor-data"; import { listFamilyMessagesForDevice } from "@/lib/monitor-data";
import { requireDeviceAuth } from "@/lib/device-auth";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const rawDeviceValue = const rawDeviceValue =
@@ -20,6 +21,7 @@ export async function GET(request: NextRequest) {
} }
try { try {
await requireDeviceAuth(request, rawDeviceValue);
const data = await listFamilyMessagesForDevice(rawDeviceValue, take); const data = await listFamilyMessagesForDevice(rawDeviceValue, take);
return NextResponse.json({ return NextResponse.json({
+13
View File
@@ -1,6 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createBindUrl, ensureDeviceRegistration } from "@/lib/monitor-data"; import { createBindUrl, ensureDeviceRegistration } from "@/lib/monitor-data";
import { createDeviceToken, hashDeviceToken } from "@/lib/device-auth";
import { prisma } from "@/lib/prisma";
export async function POST(request: Request) { export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as const body = (await request.json().catch(() => null)) as
@@ -8,6 +10,7 @@ export async function POST(request: Request) {
deviceUuid?: string; deviceUuid?: string;
displayName?: string; displayName?: string;
appVersion?: string; appVersion?: string;
deviceToken?: string;
} }
| null; | null;
@@ -26,10 +29,20 @@ export async function POST(request: Request) {
appVersion: appVersion:
typeof body.appVersion === "string" ? body.appVersion : undefined, typeof body.appVersion === "string" ? body.appVersion : undefined,
}); });
const suppliedToken = typeof body.deviceToken === "string" ? body.deviceToken.trim() : "";
const tokenMatches = suppliedToken && device.deviceTokenHash === hashDeviceToken(suppliedToken);
if (device.deviceTokenHash && !tokenMatches) {
return NextResponse.json({ error: "设备认证失败,不能覆盖已注册设备。" }, { status: 401 });
}
const deviceToken = tokenMatches ? suppliedToken : createDeviceToken();
if (!device.deviceTokenHash) {
await prisma.elderDevice.update({ where: { id: device.id }, data: { deviceTokenHash: hashDeviceToken(deviceToken) } });
}
return NextResponse.json({ return NextResponse.json({
deviceUuid: device.deviceUuid, deviceUuid: device.deviceUuid,
bindUrl: createBindUrl(device.deviceUuid), bindUrl: createBindUrl(device.deviceUuid),
deviceToken,
}); });
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(
+2
View File
@@ -2,6 +2,7 @@ import { ToolCallStatus } from "@prisma/client";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { recordToolCall } from "@/lib/monitor-data"; import { recordToolCall } from "@/lib/monitor-data";
import { requireDeviceAuth } from "@/lib/device-auth";
function toToolCallStatus(rawStatus: unknown) { function toToolCallStatus(rawStatus: unknown) {
return rawStatus === ToolCallStatus.FAILURE return rawStatus === ToolCallStatus.FAILURE
@@ -49,6 +50,7 @@ export async function POST(request: Request) {
} }
try { try {
await requireDeviceAuth(request, body.deviceUuid);
const log = await recordToolCall({ const log = await recordToolCall({
deviceUuid: body.deviceUuid, deviceUuid: body.deviceUuid,
toolName: body.toolName, toolName: body.toolName,
+2
View File
@@ -2,6 +2,7 @@ import { UsageEventType } from "@prisma/client";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { recordUsageEvent } from "@/lib/monitor-data"; import { recordUsageEvent } from "@/lib/monitor-data";
import { requireDeviceAuth } from "@/lib/device-auth";
function toUsageEventType(rawType: unknown) { function toUsageEventType(rawType: unknown) {
switch (rawType) { switch (rawType) {
@@ -46,6 +47,7 @@ export async function POST(request: Request) {
} }
try { try {
await requireDeviceAuth(request, body.deviceUuid);
await recordUsageEvent({ await recordUsageEvent({
deviceUuid: body.deviceUuid, deviceUuid: body.deviceUuid,
eventType: toUsageEventType(body.eventType), eventType: toUsageEventType(body.eventType),
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { createFamilyInvitation } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const result = await createFamilyInvitation(caregiver.id, await request.json().catch(() => ({})));
return NextResponse.json({ ok: true, ...result });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "创建邀请失败。" }, { status: 400 }); }
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { acceptFamilyInvitation } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录后接受邀请。" }, { status: 401 });
const body = await request.json().catch(() => ({})) as { token?: string };
const circle = await acceptFamilyInvitation(caregiver.id, body.token || "");
return NextResponse.json({ ok: true, circleId: circle.id });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "加入家庭失败。" }, { status: 400 }); }
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { createFamilyPost } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const body = await request.json().catch(() => ({}));
const post = await createFamilyPost(caregiver.id, body);
return NextResponse.json({ ok: true, post });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "发布失败。" }, { status: 400 }); }
}
+10 -2
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { bindDeviceToCaregiver, createBindUrl } from "@/lib/monitor-data"; import { bindDeviceToCaregiver, createBindUrl } from "@/lib/monitor-data";
import { getOrCreateCaregiverSession } from "@/lib/session"; import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) { export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as const body = (await request.json().catch(() => null)) as
@@ -18,7 +18,15 @@ export async function POST(request: Request) {
} }
try { try {
const caregiver = await getOrCreateCaregiverSession(); const caregiver = await getCaregiverSession();
if (!caregiver) {
return NextResponse.json(
{ error: "请先登录账号后再绑定设备。" },
{ status: 401 },
);
}
const device = await bindDeviceToCaregiver(caregiver.id, body.deviceUuid); const device = await bindDeviceToCaregiver(caregiver.id, body.deviceUuid);
return NextResponse.json({ return NextResponse.json({
+82
View File
@@ -0,0 +1,82 @@
import { NextResponse } from "next/server";
import {
unbindDeviceFromCaregiver,
updateCaregiverDeviceAlias,
} from "@/lib/monitor-data";
import { getDeviceName } from "@/lib/panel-format";
import { getCaregiverSession } from "@/lib/session";
export async function PATCH(request: Request) {
const body = (await request.json().catch(() => null)) as
| {
deviceUuid?: string;
alias?: string | null;
}
| null;
if (!body?.deviceUuid || typeof body.deviceUuid !== "string") {
return NextResponse.json({ error: "请先提供目标设备。" }, { status: 400 });
}
try {
const caregiver = await getCaregiverSession();
if (!caregiver) {
return NextResponse.json({ error: "请先登录账号。" }, { status: 401 });
}
const binding = await updateCaregiverDeviceAlias({
caregiverId: caregiver.id,
rawDeviceValue: body.deviceUuid,
alias: typeof body.alias === "string" || body.alias === null ? body.alias : undefined,
});
return NextResponse.json({
ok: true,
alias: binding.alias,
deviceName: getDeviceName(binding.alias, binding.elderDevice.displayName),
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "设备别名保存失败。",
},
{ status: 400 },
);
}
}
export async function DELETE(request: Request) {
const body = (await request.json().catch(() => null)) as
| {
deviceUuid?: string;
}
| null;
if (!body?.deviceUuid || typeof body.deviceUuid !== "string") {
return NextResponse.json({ error: "请先提供目标设备。" }, { status: 400 });
}
try {
const caregiver = await getCaregiverSession();
if (!caregiver) {
return NextResponse.json({ error: "请先登录账号。" }, { status: 401 });
}
const result = await unbindDeviceFromCaregiver(caregiver.id, body.deviceUuid);
return NextResponse.json({
ok: true,
deviceUuid: result.deviceUuid,
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "解绑设备失败。",
},
{ status: 400 },
);
}
}
+15 -3
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createFamilyMessageFromCaregiver } from "@/lib/monitor-data"; import { createFamilyMessageFromCaregiver } from "@/lib/monitor-data";
import { getOrCreateCaregiverSession } from "@/lib/session"; import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) { export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as const body = (await request.json().catch(() => null)) as
@@ -27,7 +27,15 @@ export async function POST(request: Request) {
} }
try { try {
const caregiver = await getOrCreateCaregiverSession(); const caregiver = await getCaregiverSession();
if (!caregiver) {
return NextResponse.json(
{ error: "请先登录账号后再发送留言。" },
{ status: 401 },
);
}
const message = await createFamilyMessageFromCaregiver({ const message = await createFamilyMessageFromCaregiver({
caregiverId: caregiver.id, caregiverId: caregiver.id,
rawDeviceValue: body.deviceUuid, rawDeviceValue: body.deviceUuid,
@@ -35,7 +43,11 @@ export async function POST(request: Request) {
importance: typeof body.importance === "string" ? body.importance : undefined, importance: typeof body.importance === "string" ? body.importance : undefined,
}); });
return NextResponse.json({ ok: true, messageId: message.id }); return NextResponse.json({
ok: true,
messageId: message.id,
publicId: message.publicId,
});
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(
{ {
+10 -32
View File
@@ -1,44 +1,22 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { import {
CAREGIVER_SESSION_COOKIE, buildCaregiverLoginPath,
createCaregiverSessionCookieValue,
getCaregiverSession, getCaregiverSession,
sanitizeCaregiverRedirectPath,
} from "@/lib/session"; } from "@/lib/session";
import { prisma } from "@/lib/prisma";
function normalizeRedirectPath(redirectTo?: string | null) {
if (!redirectTo || !redirectTo.startsWith("/") || redirectTo.startsWith("//")) {
return "/";
}
return redirectTo;
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const redirectTo = normalizeRedirectPath( const redirectTo = sanitizeCaregiverRedirectPath(
request.nextUrl.searchParams.get("redirectTo"), request.nextUrl.searchParams.get("redirectTo"),
); );
const existingCaregiver = await getCaregiverSession(); const caregiver = await getCaregiverSession();
const response = NextResponse.redirect(new URL(redirectTo, request.url)); const location = caregiver ? redirectTo : buildCaregiverLoginPath(redirectTo);
if (existingCaregiver) { return new NextResponse(null, {
return response; status: 307,
} headers: {
Location: location,
const { sessionToken, options } = createCaregiverSessionCookieValue(); },
await prisma.caregiverAccount.upsert({
where: { sessionToken },
update: {},
create: { sessionToken },
}); });
response.cookies.set({
name: CAREGIVER_SESSION_COOKIE,
value: sessionToken,
...options,
});
return response;
} }
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { createMedicationPlan } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const plan = await createMedicationPlan(caregiver.id, await request.json().catch(() => ({})));
return NextResponse.json({ ok: true, plan });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "创建用药计划失败。" }, { status: 400 }); }
}
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { createFamilyMemo } from "@/lib/family-guard";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
try {
const caregiver = await getCaregiverSession();
if (!caregiver) return NextResponse.json({ error: "请先登录。" }, { status: 401 });
const memo = await createFamilyMemo(caregiver.id, await request.json().catch(() => ({})));
return NextResponse.json({ ok: true, memo });
} catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "保存失败。" }, { status: 400 }); }
}
+77
View File
@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { getCaregiverSession } from "@/lib/session";
import { savePushSubscription } from "@/lib/push";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
| {
subscription?: {
endpoint?: string;
expirationTime?: number | null;
keys?: {
p256dh?: string;
auth?: string;
};
};
}
| null;
const rawSubscription = body?.subscription;
if (
!rawSubscription ||
typeof rawSubscription.endpoint !== "string" ||
!rawSubscription.endpoint.trim()
) {
return NextResponse.json({ error: "subscription 是必填项。" }, { status: 400 });
}
const subscription = {
endpoint: rawSubscription.endpoint,
expirationTime:
typeof rawSubscription.expirationTime === "number" ||
rawSubscription.expirationTime === null
? rawSubscription.expirationTime
: undefined,
keys:
rawSubscription.keys && typeof rawSubscription.keys === "object"
? {
p256dh:
typeof rawSubscription.keys.p256dh === "string"
? rawSubscription.keys.p256dh
: undefined,
auth:
typeof rawSubscription.keys.auth === "string"
? rawSubscription.keys.auth
: undefined,
}
: undefined,
};
try {
const caregiver = await getCaregiverSession();
if (!caregiver) {
return NextResponse.json(
{ error: "请先登录账号后再开启消息提醒。" },
{ status: 401 },
);
}
await savePushSubscription({
caregiverId: caregiver.id,
subscription,
userAgent: request.headers.get("user-agent"),
});
return NextResponse.json({ ok: true });
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "保存推送订阅失败。",
},
{ status: 400 },
);
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { sendCaregiverTestPush } from "@/lib/push";
import { getCaregiverDisplayName, getCaregiverSession } from "@/lib/session";
export async function POST() {
const caregiver = await getCaregiverSession();
if (!caregiver) {
return NextResponse.json({ error: "请先登录。" }, { status: 401 });
}
try {
const result = await sendCaregiverTestPush({
caregiverId: caregiver.id,
caregiverLabel: getCaregiverDisplayName(caregiver),
});
return NextResponse.json({
ok: true,
...result,
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "测试推送发送失败。",
},
{ status: 400 },
);
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { removePushSubscription } from "@/lib/push";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
| {
endpoint?: string;
}
| null;
if (!body?.endpoint || typeof body.endpoint !== "string") {
return NextResponse.json({ error: "endpoint 是必填项。" }, { status: 400 });
}
const caregiver = await getCaregiverSession();
if (!caregiver) {
return NextResponse.json({ ok: true });
}
await removePushSubscription({
caregiverId: caregiver.id,
endpoint: body.endpoint,
});
return NextResponse.json({ ok: true });
}
+57 -16
View File
@@ -3,7 +3,7 @@ import { redirect } from "next/navigation";
import { bindDeviceToCaregiver } from "@/lib/monitor-data"; import { bindDeviceToCaregiver } from "@/lib/monitor-data";
import { import {
buildCaregiverSessionBootstrapPath, buildCaregiverLoginPath,
getCaregiverSession, getCaregiverSession,
} from "@/lib/session"; } from "@/lib/session";
@@ -27,13 +27,13 @@ export default async function BindPage({ searchParams }: BindPageProps) {
<main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6"> <main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6">
<section className="w-full rounded-[36px] border border-white/70 bg-white/82 p-8 text-center shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur"> <section className="w-full rounded-[36px] border border-white/70 bg-white/82 p-8 text-center shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase"> <p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
连接家人设备 连接设备
</p> </p>
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]"> <h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
还没有收到设备码 还没有收到设备信息
</h1> </h1>
<p className="mt-4 text-base leading-8 text-[var(--muted)]"> <p className="mt-4 text-base leading-8 text-[var(--muted)]">
请让老人打开家人连接页,把二维码给您扫一下,或者把设备码复制过来。 请让长辈打开手机上的「家人连接」页面,把二维码给您扫一下,或将设备码复制过来。
</p> </p>
<div className="mt-6 flex justify-center gap-3"> <div className="mt-6 flex justify-center gap-3">
<Link <Link
@@ -43,10 +43,10 @@ export default async function BindPage({ searchParams }: BindPageProps) {
去扫码 去扫码
</Link> </Link>
<Link <Link
href="/" href="/devices"
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-white" className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-white"
> >
回到控制台 返回设备页
</Link> </Link>
</div> </div>
</section> </section>
@@ -58,29 +58,70 @@ export default async function BindPage({ searchParams }: BindPageProps) {
if (!caregiver) { if (!caregiver) {
redirect( redirect(
buildCaregiverSessionBootstrapPath( buildCaregiverLoginPath(
`/bind?deviceUuid=${encodeURIComponent(rawDeviceValue)}`, `/bind?deviceUuid=${encodeURIComponent(rawDeviceValue)}`,
), ),
); );
} }
const device = await bindDeviceToCaregiver(caregiver.id, rawDeviceValue); let device: Awaited<ReturnType<typeof bindDeviceToCaregiver>> | null = null;
let bindError: string | null = null;
try {
device = await bindDeviceToCaregiver(caregiver.id, rawDeviceValue);
} catch (error) {
bindError =
error instanceof Error ? error.message : "绑定失败,请稍后再试。";
}
if (!device) {
return (
<main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6">
<section className="w-full rounded-[36px] border border-white/70 bg-white/84 p-8 shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
暂时没连上
</p>
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
这次还没能完成设备连接
</h1>
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
{bindError || "请重新扫码,或请长辈重新打开设备连接页面后再试一次。"}
</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/scan"
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
>
重新扫码
</Link>
<Link
href="/devices"
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
>
返回设备页
</Link>
</div>
</section>
</main>
);
}
return ( return (
<main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6"> <main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6">
<section className="w-full rounded-[36px] border border-white/70 bg-white/84 p-8 shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur"> <section className="w-full rounded-[36px] border border-white/70 bg-white/84 p-8 shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase"> <p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
绑定完成 连接成功
</p> </p>
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]"> <h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
这位老人已经连到当前家属账号 已成功连接长辈的设备
</h1> </h1>
<p className="mt-4 text-base leading-8 text-[var(--muted)]"> <p className="mt-4 text-base leading-8 text-[var(--muted)]">
现在您可以在控制台查看老人给家里的留言、最近陪伴记录,也能马上给老人写下一句问候。 现在可以查看长辈的留言、了解使用动态,也可以随时给长辈写一句问候。
</p> </p>
<div className="mt-6 rounded-[28px] bg-[var(--paper-soft)] p-5"> <div className="mt-6 rounded-[28px] bg-[var(--paper-soft)] p-5">
<p className="text-sm text-[var(--muted)]">设备 UUID</p> <p className="text-sm text-[var(--muted)]">设备标识</p>
<p className="mt-2 break-all text-lg font-semibold text-[var(--ink)]"> <p className="mt-2 break-all text-lg font-semibold text-[var(--ink)]">
{device.deviceUuid} {device.deviceUuid}
</p> </p>
@@ -88,16 +129,16 @@ export default async function BindPage({ searchParams }: BindPageProps) {
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
<Link <Link
href="/" href={`/devices/${device.deviceUuid}`}
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]" className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
> >
回到控制台 查看设备详情
</Link> </Link>
<Link <Link
href="/scan" href="/devices"
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]" className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
> >
继续绑定其他老人 返回设备列表
</Link> </Link>
</div> </div>
</section> </section>
+78
View File
@@ -0,0 +1,78 @@
import Link from "next/link";
import { MedicationForm, MemoForm, TaskActions, TaskForm } from "@/components/family-guard-actions";
import { PanelShell } from "@/components/panel-shell";
import { requireFamilyWorkspace } from "@/lib/family-page";
import { formatDateTime } from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export const dynamic = "force-dynamic";
export default async function CarePage({ searchParams }: PageProps<"/care">) {
const caregiver = await requireCaregiverSession("/care");
const workspace = await requireFamilyWorkspace(caregiver.id);
const tab = String((await searchParams).tab || "tasks");
const deviceUuid = workspace.elderProfile.elderDevice.deviceUuid;
return (
<PanelShell currentPath="/care" title="共同照护" description="家人分工、老人确认、结果共享" caregiverLabel={getCaregiverDisplayName(caregiver)} caregiverId={caregiver.id}>
<div className="segment">
<Link className={tab === "tasks" ? "active" : ""} href="/care?tab=tasks">任务</Link>
<Link className={tab === "medications" ? "active" : ""} href="/care?tab=medications">用药</Link>
<Link className={tab === "memos" ? "active" : ""} href="/care?tab=memos">备忘</Link>
</div>
{tab === "medications" ? (
<>
<MedicationForm deviceUuid={deviceUuid} />
<section>
<div className="section-title"><h2>用药计划</h2><span>仅提醒与记录,不替代医嘱</span></div>
<div className="card">
{workspace.medicationPlans.length ? workspace.medicationPlans.map((plan) => (
<article className="timeline-item" key={plan.id}>
<div className="timeline-top strong">{plan.name}<span className="tag">{plan.active ? "进行中" : "已暂停"}</span></div>
<p className="timeline-body">每次 {plan.dosage} · {JSON.parse(plan.timesJson).join("、")}{plan.instructions ? ` · ${plan.instructions}` : ""}</p>
<p className="row-meta">信息来源:{plan.source === "PRESCRIPTION" ? "医生处方" : plan.source === "PACKAGE_LABEL" ? "药盒标签" : "家属录入"}</p>
</article>
)) : <div className="empty">还没有用药计划。</div>}
</div>
</section>
</>
) : tab === "memos" ? (
<>
<MemoForm deviceUuid={deviceUuid} />
<section>
<div className="section-title"><h2>家庭备忘录</h2></div>
<div className="card">
{workspace.memos.length ? workspace.memos.map((memo) => (
<article className="timeline-item" key={memo.id}>
<div className="timeline-top strong">{memo.title}<span className="tag">{memo.priority === "PINNED" ? "长期置顶" : memo.priority === "IMPORTANT" ? "重要" : "普通"}</span></div>
<p className="timeline-body">{memo.content}</p>
<p className="row-meta">{memo.visibility === "FAMILY_ONLY" ? "仅家人可见" : "可由数字人告诉长辈"}</p>
</article>
)) : <div className="empty">还没有家庭备忘。</div>}
</div>
</section>
</>
) : (
<>
<TaskForm deviceUuid={deviceUuid} />
<section>
<div className="section-title"><h2>照护交接</h2><span>认领后全家可见</span></div>
<div className="card">
{workspace.tasks.length ? workspace.tasks.map((task) => (
<article className="timeline-item" key={task.id}>
<div className="timeline-top strong">{task.title}<span className="timeline-time">{task.dueAt ? formatDateTime(task.dueAt) : "未设期限"}</span></div>
<p className="timeline-body">{task.description || "等待家人认领"}</p>
<p className="row-meta">{task.assignedTo ? `负责人:${task.assignedTo.nickname || task.assignedTo.username || "家人"}` : "尚未认领"}</p>
<TaskActions taskId={task.id} status={task.status} />
</article>
)) : <div className="empty">还没有照护任务。</div>}
</div>
</section>
</>
)}
</PanelShell>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { ChevronRight, Heart, MessageCircle, MessagesSquare } from "lucide-react";
import Link from "next/link";
import { PanelShell } from "@/components/panel-shell";
import { getCaregiverMessages } from "@/lib/caregiver-panel";
import { requireFamilyWorkspace } from "@/lib/family-page";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export const dynamic = "force-dynamic";
export default async function CompanionPage() {
const caregiver = await requireCaregiverSession("/companion");
const [workspace, messageOverview] = await Promise.all([
requireFamilyWorkspace(caregiver.id),
getCaregiverMessages(caregiver.id),
]);
return (
<PanelShell
currentPath="/companion"
title="温暖陪伴"
description="留言、回忆和每一次认真回应"
caregiverLabel={getCaregiverDisplayName(caregiver)}
unreadCount={messageOverview.unreadIncomingCount}
>
<section className="card">
<Link className="list-row feature-row" href="/messages">
<span className="feature-icon"><MessageCircle size={19} strokeWidth={1.8} /></span>
<span className="row-main">
<span className="row-title">双向留言</span>
<span className="row-meta">家人问候由数字人口播,长辈口述实时送达</span>
</span>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
{messageOverview.unreadIncomingCount > 0 ? <span className="count-badge">{messageOverview.unreadIncomingCount}</span> : null}
<ChevronRight className="chevron" size={16} strokeWidth={2} />
</span>
</Link>
<Link className="list-row feature-row" href="/activity">
<span className="feature-icon olive"><MessagesSquare size={19} strokeWidth={1.8} /></span>
<span className="row-main">
<span className="row-title">陪伴动态</span>
<span className="row-meta">查看数字人对话和智能服务记录</span>
</span>
<ChevronRight className="chevron" size={16} strokeWidth={2} />
</Link>
<div className="list-row feature-row feature-disabled">
<span className="feature-icon muted"><Heart size={19} strokeWidth={1.8} /></span>
<span className="row-main">
<span className="row-title">家庭记忆册</span>
<span className="row-meta">收藏长辈的语音与照片故事</span>
</span>
<span className="member-role">即将上线</span>
</div>
</section>
<section>
<div className="section-title"><h2>本周家庭连接</h2></div>
<div className="stats" style={{ gridTemplateColumns: "repeat(3, 1fr)" }}>
<div className="stat accent"><strong>{workspace.posts.length}</strong><span>家庭动态</span></div>
<div className="stat"><strong>{workspace.memos.length}</strong><span>共同备忘</span></div>
<div className="stat"><strong>{messageOverview.messages.length}</strong><span>留言往来</span></div>
</div>
<p className="muted-note" style={{ margin: "10px 2px 0" }}>数字人每天会把家人的问候念给长辈听,长辈的回应会同步到留言板。</p>
</section>
</PanelShell>
);
}
+55
View File
@@ -0,0 +1,55 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ManageDeviceForm, SendMessageForm } from "@/components/dashboard-actions";
import { PanelShell } from "@/components/panel-shell";
import { getCaregiverDeviceDetail } from "@/lib/caregiver-panel";
import { formatDateTime, getConversationRoleLabel, getDeviceName, getMessageHref, getUsageLabel, truncateText } from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export const dynamic = "force-dynamic";
export default async function DeviceDetailPage({ params, searchParams }: PageProps<"/devices/[deviceUuid]">) {
const { deviceUuid } = await params;
const caregiver = await requireCaregiverSession(`/devices/${deviceUuid}`);
const binding = await getCaregiverDeviceDetail(caregiver.id, deviceUuid);
if (!binding) notFound();
const rawTab = (await searchParams).tab;
const tab = rawTab === "chat" || rawTab === "usage" ? rawTab : "msg";
const messages = binding.elderDevice.messages;
return (
<PanelShell currentPath="/devices" title={getDeviceName(binding.alias, binding.elderDevice.displayName)} caregiverLabel={getCaregiverDisplayName(caregiver)} backHref="/devices"
actions={<span className="row-meta" style={{ margin: 0 }}><span className="dot online" style={{ margin: 0 }} />{formatDateTime(binding.elderDevice.lastSeenAt)}</span>}>
<section className="stats" style={{ gridTemplateColumns: "repeat(3,1fr)" }}>
<div className="stat accent"><strong>{binding.elderUnreadCount}</strong><span>新留言</span></div>
<div className="stat"><strong>{binding.familyUnreadCount}</strong><span>待长辈听</span></div>
<div className="stat"><strong>{binding.elderDevice._count.conversationTurns}</strong><span>陪伴对话</span></div>
</section>
<SendMessageForm deviceUuid={binding.elderDevice.deviceUuid} />
<div className="segment">
<Link href={`/devices/${deviceUuid}?tab=msg`} className={tab === "msg" ? "active" : ""}>留言</Link>
<Link href={`/devices/${deviceUuid}?tab=chat`} className={tab === "chat" ? "active" : ""}>对话</Link>
<Link href={`/devices/${deviceUuid}?tab=usage`} className={tab === "usage" ? "active" : ""}>使用</Link>
</div>
<section className="card">
{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>
)) : <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="empty">还没有聊天记录。</div>) : (binding.elderDevice.usageEvents.length ? binding.elderDevice.usageEvents.map((event) => (
<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>
)) : <div className="empty">暂时还没有使用记录。</div>)}
</section>
<details className="device-manage"><summary className="btn block">设备设置</summary><ManageDeviceForm deviceUuid={binding.elderDevice.deviceUuid} initialAlias={binding.alias} deviceLabel={getDeviceName(binding.elderDevice.displayName)} /></details>
</PanelShell>
);
}
+52
View File
@@ -0,0 +1,52 @@
import Link from "next/link";
import { ChevronRight, Smartphone } from "lucide-react";
import { BindDeviceForm } from "@/components/dashboard-actions";
import { PanelShell } from "@/components/panel-shell";
import { getCaregiverDevices } from "@/lib/caregiver-panel";
import { formatDateTime, getDeviceName } from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export const dynamic = "force-dynamic";
export default async function DevicesPage() {
const caregiver = await requireCaregiverSession("/devices");
const devices = await getCaregiverDevices(caregiver.id);
const unread = devices.reduce((n, d) => n + d.elderUnreadCount, 0);
return (
<PanelShell currentPath="/devices" title="我的设备" caregiverLabel={getCaregiverDisplayName(caregiver)} unreadCount={unread}
actions={<Link href="/scan" className="btn small">扫码绑定</Link>}>
<section className="card">
{devices.length ? devices.map((binding) => (
<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>
<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 />
<details className="card">
<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>
</summary>
<div className="card-pad" style={{ borderTop: "1px solid var(--line)", color: "var(--muted)", fontSize: 12.5, lineHeight: 1.7 }}>
<p>1. 让长辈打开陪伴屏上的「家人连接」页面。</p>
<p>2. 扫描二维码,或把设备码粘贴到上方输入框。</p>
<p>3. 连接后即可收到长辈的留言、对话与使用动态。</p>
</div>
</details>
</PanelShell>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { JoinFamilyButton } from "@/components/family-guard-actions";
import { requireCaregiverSession } from "@/lib/page-auth";
export default async function JoinFamilyPage({ searchParams }: PageProps<"/family/join">) {
const token = String((await searchParams).token || "");
await requireCaregiverSession(`/family/join?token=${encodeURIComponent(token)}`);
return <main className="app-frame"><div className="app-content app-stack"><section className="card card-pad" style={{ marginTop: 48 }}><h1 style={{ margin: 0 }}>加入家庭守护圈</h1><p className="timeline-body">加入后,你将根据邀请角色参与长辈的家庭动态和共同照护。</p><JoinFamilyButton token={token} /></section></div></main>;
}
+77
View File
@@ -0,0 +1,77 @@
import { FamilyPostForm, InviteMemberForm } from "@/components/family-guard-actions";
import { PanelShell } from "@/components/panel-shell";
import { requireFamilyWorkspace } from "@/lib/family-page";
import { formatDateTime } from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export const dynamic = "force-dynamic";
const roleLabel = { ADMIN: "管理员", CAREGIVER: "照护成员", CARING: "关怀成员" } as const;
const avatarStyles = [
{ background: "rgba(192,90,30,.1)", color: "var(--accent)" },
{ background: "rgba(101,118,90,.12)", color: "var(--olive)" },
{ background: "rgba(33,30,26,.06)", color: "var(--muted)" },
];
export default async function FamilyPage() {
const caregiver = await requireCaregiverSession("/family");
const workspace = await requireFamilyWorkspace(caregiver.id);
const deviceUuid = workspace.elderProfile.elderDevice.deviceUuid;
return (
<PanelShell
currentPath="/family"
title={workspace.name}
description={`${workspace.memberships.length} 位家人共同参与`}
caregiverLabel={getCaregiverDisplayName(caregiver)}
caregiverId={caregiver.id}
>
<FamilyPostForm deviceUuid={deviceUuid} />
<section>
<div className="section-title"><h2>家庭动态</h2></div>
<div className="card">
{workspace.posts.length ? workspace.posts.map((post) => (
<article key={post.id} className="timeline-item">
<div className="timeline-top">
{post.elderAuthored ? `${workspace.elderProfile.preferredName}通过安智伴说` : post.author?.nickname || post.author?.username || "家里人"}
<span className="timeline-time">{formatDateTime(post.createdAt)}</span>
</div>
<p className="timeline-body">{post.content}</p>
<span className="tag" style={{ marginTop: 8 }}>
{post.visibility === "FAMILY_ONLY" ? "仅家人" : post.visibility === "ELDER_ONLY" ? "只给长辈" : "全家可见"}
</span>
</article>
)) : <div className="empty">发出第一条家庭动态,让关心流动起来。</div>}
</div>
</section>
<section>
<div className="section-title"><h2>家庭成员</h2></div>
<div className="card">
{workspace.memberships.map((member, index) => {
const name = member.caregiver.nickname || member.caregiver.username || "家庭成员";
return (
<div key={member.id} className="list-row" style={{ alignItems: "center" }}>
<span className="member-avatar" style={avatarStyles[index % avatarStyles.length]}>{name.slice(-1)}</span>
<span className="row-main">
<span className="row-title">{name}</span>
<span className="row-meta">
{member.relationLabel || "家人"}{member.dutyOrder ? ` · 值守顺序 ${member.dutyOrder}` : ""}
</span>
</span>
<span className="member-role">{roleLabel[member.role]}</span>
</div>
);
})}
</div>
</section>
<section>
<div className="section-title"><h2>邀请家人</h2><span>链接 24 小时有效</span></div>
<InviteMemberForm deviceUuid={deviceUuid} />
</section>
</PanelShell>
);
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 31 KiB

+193 -50
View File
@@ -1,17 +1,31 @@
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
--paper: #f4ece0; --canvas: #e7e4df;
--paper-soft: #fbf7f1; --paper: #f5f4f2;
--paper-strong: #fffdf8; --surface: #ffffff;
--ink: #241b14; --surface-soft: #faf9f8;
--ink-soft: #3d2d20; --ink: #211e1a;
--muted: #726153; --ink-soft: #3d3833;
--line: rgba(85, 60, 42, 0.15); --muted: #8b857c;
--copper: #c76733; --muted-light: #b5afa6;
--copper-deep: #964722; --line: rgba(33, 30, 26, 0.08);
--olive: #76845e; --line-strong: rgba(33, 30, 26, 0.15);
--olive-deep: #596946; --accent: #c05a1e;
--accent-hover: #96431a;
--accent-soft: rgba(192, 90, 30, 0.09);
--copper: var(--accent);
--copper-deep: var(--accent-hover);
--paper-soft: var(--surface-soft);
--olive: #65765a;
--olive-deep: #4f6046;
--success: #3f9d63;
--danger: #b4482c;
--radius-sm: 8px;
--radius-md: 10px;
--radius-lg: 14px;
--font-body: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--font-display: var(--font-body);
} }
@theme inline { @theme inline {
@@ -20,47 +34,176 @@
--font-sans: var(--font-body); --font-sans: var(--font-body);
} }
* { * { 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; }
button:disabled { cursor: not-allowed; opacity: .6; }
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(33, 30, 26, .03);
}
.app-content { min-height: 100dvh; padding: 22px 18px 110px; }
.app-content.detail { padding-top: 0; padding-bottom: 28px; }
.app-stack { display: flex; flex-direction: column; gap: 18px; }
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 2px; }
.page-heading h1 { margin: 0; font-size: 21px; line-height: 1.35; font-weight: 700; letter-spacing: .01em; }
.page-heading p { margin: 4px 0 0; color: var(--muted); font-size: 12.5px; line-height: 1.5; }
.avatar { display: flex; width: 36px; height: 36px; 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: 58px; align-items: center; gap: 7px; padding: 11px 0; background: rgba(245, 244, 242, .96); backdrop-filter: blur(10px); }
.detail-header h1 { margin: 0; flex: 1; font-size: 16px; font-weight: 700; }
.back-button { display: flex; width: 32px; height: 32px; 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: 15px 16px; }
.list-row { display: flex; width: 100%; align-items: flex-start; gap: 11px; padding: 12px 16px; 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: 3px; color: var(--muted); font-size: 12px; line-height: 1.5; }
.dot { width: 7px; height: 7px; flex: none; margin-top: 6px; border-radius: 50%; background: var(--accent); }
.dot.online { background: var(--success); animation: pulse 2.4s ease-in-out infinite; }
.dot.offline { background: #c7c2ba; }
.tag { display: inline-flex; align-items: center; border-radius: 5px; padding: 2px 7px; background: var(--accent-soft); color: var(--accent); font-size: 11px; font-weight: 600; }
.tag.success { background: rgba(63, 157, 99, .1); color: var(--success); }
.section-title { display: flex; align-items: baseline; justify-content: space-between; padding: 0 2px 9px; }
.section-title h2 { margin: 0; font-size: 15px; font-weight: 700; }
.section-title > span { color: var(--muted-light); font-size: 12px; }
.section-title a { color: var(--accent); font-size: 12.5px; font-weight: 600; }
.stats { display: grid; overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); }
.stat { padding: 13px 4px; text-align: center; }
.stat + .stat { border-left: 1px solid var(--line); }
.stat strong { display: block; font-size: 21px; line-height: 1.2; font-weight: 700; }
.stat:first-child strong, .stat.accent strong { color: var(--accent); }
.stat span { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; }
.button-row { display: flex; gap: 8px; }
.btn { display: inline-flex; min-height: 44px; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--line-strong); border-radius: var(--radius-md); padding: 0 13px; background: var(--surface); color: var(--ink); font-size: 14px; font-weight: 600; white-space: nowrap; }
.btn:hover { background: var(--surface-soft); }
.btn.primary { border-color: var(--accent); background: var(--accent); color: #fff; }
.btn.primary:hover { border-color: var(--accent-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 13px; font-size: 12.5px; }
.btn.block { width: 100%; }
.btn.muted { color: var(--muted); }
.segment { display: flex; gap: 2px; padding: 3px; border-radius: 11px; background: rgba(33, 30, 26, .06); }
.segment a, .segment button { flex: 1; height: 34px; border: 0; border-radius: 8px; background: transparent; color: var(--muted); font-size: 13px; font-weight: 600; text-align: center; line-height: 34px; }
.segment .active { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(0, 0, 0, .08); }
.field-label { display: block; margin: 0 0 7px; color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: .02em; }
.field { width: 100%; height: 44px; border: 1px solid var(--line-strong); border-radius: 10px; padding: 0 13px; outline: 0; background: #fff; color: var(--ink); font-size: 14px; }
.field::placeholder { color: var(--muted-light); }
.field:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
textarea.field { height: auto; min-height: 78px; padding: 11px 13px; line-height: 1.6; background: var(--surface-soft); }
select.field {
appearance: none;
padding-right: 34px;
cursor: pointer;
background-color: #fff;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15' viewBox='0 0 24 24' fill='none' stroke='%238b857c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-position: right 11px center;
background-repeat: no-repeat;
}
.form-stack { display: flex; flex-direction: column; gap: 10px; }
.form-grid { display: grid; gap: 10px; }
.form-grid.two { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.form-grid.action { grid-template-columns: minmax(0, 1fr) 110px; }
.form-grid.publish { grid-template-columns: minmax(0, 1fr) 96px; }
.form-grid.memo-actions { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 82px; }
.form-notice { margin: 9px 0 0; color: var(--success); font-size: 12px; line-height: 1.5; }
.elder-status { overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); }
.elder-status-main { display: flex; align-items: center; gap: 12px; padding: 15px 16px; }
.elder-avatar { display: flex; width: 46px; height: 46px; flex: none; align-items: center; justify-content: center; border-radius: 50%; background: rgba(101, 118, 90, .12); color: var(--olive); font-size: 18px; font-weight: 700; }
.elder-name { display: flex; align-items: center; gap: 7px; font-size: 15px; font-weight: 700; }
.online-label { font-size: 12px; font-weight: 600; }
.elder-meta { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; }
.checkin-action { border-top: 1px solid var(--line); padding: 12px 16px 14px; }
.checkin-action .form-notice { text-align: center; }
.timeline-item { padding: 13px 16px; 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: 13px; font-weight: 600; }
.timeline-top.strong { color: var(--ink); font-size: 13.5px; font-weight: 700; }
.timeline-time { margin-left: auto; color: var(--muted-light); font-size: 11.5px; font-weight: 400; white-space: nowrap; }
.timeline-body { margin: 5px 0 0; color: var(--ink-soft); font-size: 13px; line-height: 1.6; }
.timeline-item > .row-meta { margin-top: 5px; }
.timeline-item > .button-row { margin-top: 10px; }
.alert-title { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); font-size: 13.5px; font-weight: 700; }
.alert-title.red { color: #b33a2b; }
.member-avatar { display: flex; width: 36px; height: 36px; flex: none; align-items: center; justify-content: center; border-radius: 50%; font-size: 14px; font-weight: 700; }
.member-role { flex: none; border-radius: 5px; padding: 2px 7px; background: rgba(33, 30, 26, .05); color: var(--muted); font-size: 11px; font-weight: 600; }
.feature-row { align-items: center; gap: 12px; padding: 14px 16px; }
.feature-icon { display: flex; width: 40px; height: 40px; flex: none; align-items: center; justify-content: center; border-radius: 11px; background: var(--accent-soft); color: var(--accent); }
.feature-icon.olive { background: rgba(101, 118, 90, .1); color: var(--olive); }
.feature-icon.muted { background: rgba(33, 30, 26, .05); color: var(--muted); }
.feature-disabled { opacity: .55; }
.chevron { flex: none; color: var(--muted-light); }
.count-badge { min-width: 18px; height: 18px; border-radius: 9px; padding: 0 5px; background: var(--accent); color: #fff; font-size: 11px; font-weight: 700; line-height: 18px; text-align: center; }
.settings-profile { display: flex; align-items: center; gap: 13px; padding: 16px; }
.settings-profile .avatar { width: 48px; height: 48px; font-size: 18px; }
.settings-row { display: flex; width: 100%; align-items: center; gap: 12px; padding: 13px 16px; border: 0; border-top: 1px solid var(--line); background: transparent; color: var(--ink); text-align: left; }
.settings-row:first-child { border-top: 0; }
.settings-row:hover { background: var(--surface-soft); }
.settings-icon { display: flex; width: 36px; height: 36px; flex: none; align-items: center; justify-content: center; border-radius: 10px; background: var(--accent-soft); color: var(--accent); }
.settings-icon.olive { background: rgba(101, 118, 90, .1); color: var(--olive); }
.settings-icon.muted { background: rgba(33, 30, 26, .05); color: var(--muted); }
.toggle { width: 46px; height: 27px; flex: none; position: relative; border: 0; border-radius: 14px; background: rgba(33, 30, 26, .18); transition: background .2s; }
.toggle.on { background: var(--success); }
.toggle-knob { position: absolute; top: 3px; left: 3px; width: 21px; height: 21px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .2); transition: left .2s; }
.toggle.on .toggle-knob { left: 22px; }
.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(5, 1fr); width: 100%; max-width: 430px; margin: 0 auto; padding: 8px 4px calc(18px + env(safe-area-inset-bottom)); border-top: 1px solid var(--line); background: rgba(255, 255, 255, .94); backdrop-filter: blur(12px); }
.nav-item { position: relative; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 5px 0; color: #9a948b; font-size: 10.5px; font-weight: 600; }
.nav-item.active { color: var(--accent); }
.nav-badge { position: absolute; top: 1px; left: calc(50% + 7px); min-width: 16px; height: 16px; border-radius: 8px; padding: 0 4px; background: var(--accent); color: #fff; font-size: 10px; font-weight: 700; 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); }
.settings-controls > * { border: 1px solid var(--line) !important; border-radius: 14px !important; background: #fff !important; padding: 15px 16px !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: 14px !important; background: #fff !important; padding: 15px 16px !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; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
@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; }
@media (max-width: 390px) {
.form-grid.memo-actions { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.form-grid.memo-actions .btn { grid-column: 1 / -1; }
.stats .stat strong { font-size: 19px; }
} }
html { @media (min-width: 700px) {
background: #ecdfcf; .app-frame { min-height: calc(100dvh - 72px); margin: 36px auto; border-radius: 30px; box-shadow: 0 24px 70px rgba(33, 30, 26, .16), 0 0 0 1px rgba(33, 30, 26, .05); }
.app-content { min-height: calc(100dvh - 72px); }
.bottom-nav { bottom: 36px; border-radius: 0 0 30px 30px; }
} }
body { @media (prefers-reduced-motion: reduce) {
color: var(--ink); *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
font-family: var(--font-body), sans-serif;
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.82), transparent 34%),
radial-gradient(circle at 88% 10%, rgba(205, 126, 76, 0.22), transparent 22%),
linear-gradient(180deg, #f6eee1 0%, #efe2d1 52%, #e8d6c3 100%);
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
textarea,
select {
font: inherit;
}
::selection {
background: rgba(199, 103, 51, 0.18);
}
.paper-panel {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 249, 241, 0.84)),
repeating-linear-gradient(
135deg,
rgba(198, 163, 127, 0.06),
rgba(198, 163, 127, 0.06) 10px,
rgba(255, 255, 255, 0.08) 10px,
rgba(255, 255, 255, 0.08) 20px
);
} }
+99
View File
@@ -0,0 +1,99 @@
import { AlertTriangle, CheckCircle2, HeartPulse, PhoneCall } from "lucide-react";
import { AlertActions, CheckInButton } from "@/components/family-guard-actions";
import { PanelShell } from "@/components/panel-shell";
import { requireFamilyWorkspace } from "@/lib/family-page";
import { formatDateTime } from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export const dynamic = "force-dynamic";
export default async function GuardPage() {
const caregiver = await requireCaregiverSession("/guard");
const workspace = await requireFamilyWorkspace(caregiver.id);
const elder = workspace.elderProfile;
const device = elder.elderDevice;
const lastSeenAt = device.lastSeenAt;
const isOnline = Boolean(lastSeenAt);
const openAlerts = workspace.alerts.filter((alert) => alert.status === "OPEN" || alert.status === "ACKNOWLEDGED");
const today = new Date().toDateString();
const todayDoses = workspace.medicationPlans.flatMap((plan) => plan.doses).filter((dose) => dose.scheduledFor.toDateString() === today);
const doneDoses = todayDoses.filter((dose) => dose.status === "TAKEN").length;
const openTasks = workspace.tasks.filter((task) => task.status !== "COMPLETED" && task.status !== "CANCELLED");
const pendingCheckIns = workspace.checkIns.filter((checkIn) => checkIn.status === "PENDING");
const dutyNames = workspace.memberships
.filter((membership) => membership.emergencyPush)
.map((membership) => membership.caregiver.nickname || membership.caregiver.username || "家人");
return (
<PanelShell
currentPath="/guard"
title="家庭守护"
description={`${elder.preferredName} · 风险线索不是医疗诊断`}
caregiverLabel={getCaregiverDisplayName(caregiver)}
caregiverId={caregiver.id}
>
<section className="elder-status">
<div className="elder-status-main">
<span className="elder-avatar">{elder.preferredName.slice(0, 1)}</span>
<span className="row-main">
<span className="elder-name">
{elder.preferredName}
<span className={`dot ${isOnline ? "online" : "offline"}`} style={{ marginTop: 0 }} />
<span className="online-label" style={{ color: isOnline ? "var(--success)" : "var(--muted-light)" }}>
{isOnline ? "在线" : "离线"}
</span>
</span>
<span className="elder-meta">
{isOnline ? `最近与数字人互动 · ${device.displayName || "长辈终端"}` : `最近在线:${formatDateTime(lastSeenAt)} · ${device.displayName || "长辈终端"}`}
</span>
</span>
</div>
<CheckInButton deviceUuid={device.deviceUuid} />
</section>
<section className="stats" style={{ gridTemplateColumns: "repeat(4, 1fr)" }}>
<div className="stat accent"><strong>{openAlerts.length}</strong><span>待处理提醒</span></div>
<div className="stat"><strong>{doneDoses}/{todayDoses.length}</strong><span>今日用药</span></div>
<div className="stat"><strong>{openTasks.length}</strong><span>照护事项</span></div>
<div className="stat"><strong>{pendingCheckIns.length}</strong><span>待报平安</span></div>
</section>
<section>
<div className="section-title"><h2>风险提醒</h2><span>人工确认后再处置</span></div>
<div className="card">
{workspace.alerts.length ? workspace.alerts.map((alert) => (
<article id={`alert-${alert.id}`} key={alert.id} className="timeline-item">
<div className="timeline-top">
<span className={`alert-title${alert.level === "RED" ? " red" : ""}`}>
<AlertTriangle size={15} strokeWidth={2} />{alert.title}
</span>
<span className="timeline-time">{formatDateTime(alert.createdAt)}</span>
</div>
<p className="timeline-body">{alert.summary}</p>
<p className="row-meta">{alert.claimedBy ? `已由 ${alert.claimedBy.nickname || alert.claimedBy.username || "家人"} 接手` : "尚未认领"}</p>
<AlertActions alertId={alert.id} status={alert.status} />
</article>
)) : (
<div className="empty"><CheckCircle2 size={26} /><br />当前没有待处理的风险提醒</div>
)}
</div>
</section>
<section>
<div className="section-title"><h2>今日状态</h2></div>
<div className="card">
<article className="timeline-item">
<div className="timeline-top"><HeartPulse size={15} />设备最近在线<span className="timeline-time">{formatDateTime(lastSeenAt)}</span></div>
<p className="timeline-body">仅根据终端在线和主动交互记录展示,不代表生命体征状态。</p>
</article>
<article className="timeline-item">
<div className="timeline-top"><PhoneCall size={15} />家庭值守</div>
<p className="timeline-body">{dutyNames.length ? `${dutyNames.join("、")}已开启紧急推送` : "尚未设置家庭值守"}</p>
</article>
</div>
</section>
</PanelShell>
);
}
+33 -17
View File
@@ -1,23 +1,37 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import { Noto_Sans_SC, Noto_Serif_SC } from "next/font/google";
import "./globals.css"; import "./globals.css";
const bodyFont = Noto_Sans_SC({ import { NavigationProgressProvider } from "@/components/navigation-progress";
variable: "--font-body",
subsets: ["latin"],
weight: ["400", "500", "700"],
});
const displayFont = Noto_Serif_SC({
variable: "--font-display",
subsets: ["latin"],
weight: ["400", "600", "700"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL("https://digital-human.xn--876a.net"), metadataBase: new URL("https://digital-human.xn--876a.net"),
title: "家人连线台", title: "安智伴 家人连线台",
description: "给老人设备做绑定、查看留言、追踪陪伴记录的家属端。", description: "随时了解长辈的近况,查看留言、使用动态,给长辈写问候。",
manifest: "/manifest.webmanifest",
icons: {
icon: {
url: "/app-icon.webp",
sizes: "512x512",
type: "image/webp",
},
apple: [
{
url: "/pwa/apple-touch-icon.webp",
sizes: "180x180",
type: "image/webp",
},
],
shortcut: "/app-icon.webp",
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "安智伴 家人连线台",
},
};
export const viewport: Viewport = {
themeColor: "#F5F4F2",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -26,8 +40,10 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="zh-CN" className={`${bodyFont.variable} ${displayFont.variable}`}> <html lang="zh-CN">
<body className="min-h-screen">{children}</body> <body className="min-h-screen">
<NavigationProgressProvider>{children}</NavigationProgressProvider>
</body>
</html> </html>
); );
} }
+35
View File
@@ -0,0 +1,35 @@
import Image from "next/image";
import { redirect } from "next/navigation";
import { AccountAuthForm } from "@/components/account-auth-form";
import { getCaregiverSession, sanitizeCaregiverRedirectPath } from "@/lib/session";
export const dynamic = "force-dynamic";
export default async function LoginPage({ searchParams }: PageProps<"/login">) {
const caregiver = await getCaregiverSession();
const raw = (await searchParams).redirectTo;
const redirectTo = sanitizeCaregiverRedirectPath(typeof raw === "string" ? raw : null);
if (caregiver) redirect(redirectTo);
return (
<main className="app-frame" style={{ display: "flex", alignItems: "center" }}>
<section style={{ width: "100%", padding: "28px 22px", display: "flex", flexDirection: "column", gap: 22 }}>
<div>
<Image
src="/app-icon.webp"
alt="安智伴"
width={52}
height={52}
priority
style={{ display: "block", borderRadius: 14 }}
/>
<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>
</div>
<AccountAuthForm redirectTo={redirectTo} />
<p className="muted-note" style={{ textAlign: "center" }}>登录后会自动回到刚才的页面,扫码和绑定流程不用重新开始。</p>
</section>
</main>
);
}
+33
View File
@@ -0,0 +1,33 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "安智伴 家人连线台",
short_name: "安智伴 家人连线台",
description: "随时了解长辈的近况,查看留言、使用动态,给长辈写问候。",
start_url: "/",
display: "standalone",
background_color: "#f6eee1",
theme_color: "#FBFAF6",
lang: "zh-CN",
categories: ["utilities", "lifestyle", "health"],
icons: [
{
src: "/pwa/icon-192x192.webp",
sizes: "192x192",
type: "image/webp",
},
{
src: "/pwa/icon-512x512.webp",
sizes: "512x512",
type: "image/webp",
},
{
src: "/pwa/icon-maskable-512x512.webp",
sizes: "512x512",
type: "image/webp",
purpose: "maskable",
},
],
};
}
+37
View File
@@ -0,0 +1,37 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { PanelShell } from "@/components/panel-shell";
import { getCaregiverMessageDetail } from "@/lib/caregiver-panel";
import { formatDateTime, getDeviceName, getMessageDirectionLabel, getMessageStatusLabel } from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export const dynamic = "force-dynamic";
export default async function MessageDetailPage({ params }: PageProps<"/messages/[messageId]">) {
const publicId = Number.parseInt((await params).messageId, 10);
if (!Number.isFinite(publicId) || publicId <= 0) notFound();
const caregiver = await requireCaregiverSession(`/messages/${publicId}`);
const message = await getCaregiverMessageDetail(caregiver.id, publicId);
if (!message) notFound();
return (
<PanelShell currentPath="/messages" title={`留言 #${message.publicId}`} caregiverLabel={getCaregiverDisplayName(caregiver)} backHref="/messages">
<article className="card card-pad" style={{ paddingTop: 16, paddingBottom: 16 }}>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<span className="tag" style={{ color: "var(--muted)", background: "rgba(32,29,26,.06)" }}>{getMessageDirectionLabel(message.direction)}</span>
{message.importance !== "NORMAL" ? <span className="tag">重要</span> : null}
<span className="tag" style={{ color: "var(--muted)", background: "rgba(32,29,26,.06)" }}>{getMessageStatusLabel(message)}</span>
</div>
<p style={{ margin: "12px 0 0", fontSize: 17, lineHeight: 1.65 }}>{message.content}</p>
</article>
<section className="card">
<div className="list-row"><span style={{ color: "var(--muted)" }}>所属设备</span><strong style={{ marginLeft: "auto", fontSize: 13 }}>{getDeviceName(message.elderDevice.displayName)}</strong></div>
<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>
</section>
<Link href={`/devices/${message.elderDevice.deviceUuid}`} className="btn primary block">回一句问候</Link>
</PanelShell>
);
}
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import { PenLine } from "lucide-react";
import { PanelShell } from "@/components/panel-shell";
import { getCaregiverMessages } from "@/lib/caregiver-panel";
import { formatDateTime, getDeviceName, getMessageHref, getMessageStatusLabel, truncateText } from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export const dynamic = "force-dynamic";
export default async function MessagesPage({ searchParams }: PageProps<"/messages">) {
const caregiver = await requireCaregiverSession("/messages");
const { messages, unreadIncomingCount } = 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");
return (
<PanelShell currentPath="/messages" title="留言板" caregiverLabel={getCaregiverDisplayName(caregiver)} unreadCount={unreadIncomingCount}
actions={<Link href="/devices" className="btn primary small"><PenLine size={14}/>写留言</Link>}>
<div className="segment">
<Link href="/messages?filter=in" className={filter === "in" ? "active" : ""}>收到 · {unreadIncomingCount} 未读</Link>
<Link href="/messages?filter=out" className={filter === "out" ? "active" : ""}>发出</Link>
</div>
<section className="card">
{items.length ? items.map((message) => (
<Link key={message.id} href={getMessageHref(message.publicId)} className="list-row">
<span className="dot" style={{ background: message.readAt ? "transparent" : "var(--accent)" }} />
<span className="row-main">
<span className="row-meta" style={{ marginTop: 0 }}>
<strong style={{ color: "var(--ink-soft)" }}>{getDeviceName(message.elderDevice.displayName)}</strong>
{message.importance !== "NORMAL" ? <span className="tag">重要</span> : null}
<span style={{ marginLeft: "auto", color: "var(--muted-light)" }}>{formatDateTime(message.createdAt)}</span>
</span>
<span className="row-title" style={{ marginTop: 4, fontWeight: message.readAt ? 400 : 600 }}>{truncateText(message.content, 82)}</span>
<span className="row-meta">#{message.publicId} · {getMessageStatusLabel(message)}</span>
</span>
</Link>
)) : <div className="empty">这一侧暂时还没有留言。</div>}
</section>
</PanelShell>
);
}
+1 -438
View File
@@ -1,439 +1,2 @@
import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default function Home() { redirect("/guard"); }
import { BindDeviceForm, SendMessageForm } from "@/components/dashboard-actions";
import { getCaregiverDashboard } from "@/lib/monitor-data";
import {
buildCaregiverSessionBootstrapPath,
getCaregiverSession,
} from "@/lib/session";
export const dynamic = "force-dynamic";
function formatTime(date: Date | null | undefined) {
if (!date) {
return "暂时还没有记录";
}
return new Intl.DateTimeFormat("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function getImportanceLabel(level: string) {
switch (level) {
case "LOW":
return "温和提醒";
case "HIGH":
return "希望尽快看到";
case "URGENT":
return "需要尽快联系";
default:
return "日常问候";
}
}
function getUsageLabel(eventType: string) {
switch (eventType) {
case "APP_OPEN":
return "打开了老人端应用";
case "SETTINGS_OPENED":
return "打开了家人连接页";
case "AI_SESSION_STARTED":
return "开始了一次陪伴对话";
case "AI_SESSION_ENDED":
return "结束了一次陪伴对话";
case "CAMERA_ENABLED":
return "打开了镜头模式";
case "CAMERA_DISABLED":
return "关闭了镜头模式";
case "TOOL_CALLED":
return "模型调用了一次桥接工具";
default:
return eventType;
}
}
export default async function Home() {
const caregiver = await getCaregiverSession();
if (!caregiver) {
redirect(buildCaregiverSessionBootstrapPath("/"));
}
const dashboard = await getCaregiverDashboard(caregiver.id);
const totalUnreadForElder = dashboard.devices.reduce(
(total, device) => total + device.familyUnreadCount,
0,
);
const totalUnreadForCaregiver = dashboard.devices.reduce(
(total, device) => total + device.elderUnreadCount,
0,
);
const totalConversations = dashboard.devices.reduce(
(total, device) => total + device.elderDevice._count.conversationTurns,
0,
);
return (
<main className="relative overflow-hidden px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
<div className="pointer-events-none absolute inset-x-0 top-0 h-64 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.78),transparent_68%)]" />
<div className="mx-auto max-w-7xl space-y-6">
<section className="paper-panel relative overflow-hidden rounded-[36px] border border-white/70 px-6 py-8 shadow-[0_35px_90px_rgba(105,68,41,0.13)] sm:px-8 lg:px-10 lg:py-10">
<div className="absolute -right-10 top-[-52px] h-40 w-40 rounded-full bg-[rgba(200,103,51,0.10)] blur-2xl" />
<div className="absolute left-10 top-8 h-20 w-20 rounded-full border border-[rgba(118,132,94,0.18)]" />
<div className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr] lg:items-end">
<div>
<p className="text-sm font-semibold tracking-[0.24em] text-[var(--copper)] uppercase">
Digital Human Bridge
</p>
<h1 className="mt-4 max-w-3xl font-[family-name:var(--font-display)] text-4xl leading-[1.15] text-[var(--ink)] sm:text-5xl">
把老人的陪伴对话、留言和家人问候,收进同一张暖色桌面。
</h1>
<p className="mt-5 max-w-2xl text-base leading-8 text-[var(--muted)] sm:text-lg">
这里是家属端控制台。扫码绑定以后,您能看到老人留给家里的话、老人最近的使用轨迹、重要对话片段,也能反过来给老人写下一句问候。
</p>
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
<span className="rounded-full border border-[var(--line)] bg-white/70 px-4 py-2">
当前家属账号:{dashboard.caregiver.sessionToken.slice(0, 8).toUpperCase()}
</span>
<span className="rounded-full border border-[var(--line)] bg-white/70 px-4 py-2">
已连接 {dashboard.devices.length} 位老人设备
</span>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
<p className="text-sm text-[var(--muted)]">家属已绑定设备</p>
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
{dashboard.devices.length}
</p>
</div>
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
<p className="text-sm text-[var(--muted)]">老人未读家属留言</p>
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
{totalUnreadForElder}
</p>
</div>
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
<p className="text-sm text-[var(--muted)]">最近存档对话条数</p>
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
{totalConversations}
</p>
</div>
</div>
</div>
</section>
<BindDeviceForm />
<section className="grid gap-6 lg:grid-cols-[0.85fr_1.15fr]">
<div className="rounded-[32px] border border-white/70 bg-white/78 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)] backdrop-blur">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
今日总览
</p>
<div className="mt-4 space-y-4">
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
<p className="text-sm text-[var(--muted)]">老人给家里的新留言</p>
<p className="mt-2 text-3xl font-semibold text-[var(--ink)]">
{totalUnreadForCaregiver}
</p>
</div>
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
<p className="text-sm text-[var(--muted)]">老人待接收的家属留言</p>
<p className="mt-2 text-3xl font-semibold text-[var(--ink)]">
{totalUnreadForElder}
</p>
</div>
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
<p className="text-sm text-[var(--muted)]">扫码绑定更顺手</p>
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
如果您正拿着另一台手机,可以直接打开扫码页,对准老人的家人连接二维码。
</p>
<Link
href="/scan"
className="mt-4 inline-flex h-11 items-center justify-center rounded-full bg-[var(--olive)] px-4 text-sm font-semibold text-white transition hover:bg-[var(--olive-deep)]"
>
去扫码
</Link>
</div>
</div>
</div>
<div className="rounded-[32px] border border-white/70 bg-white/78 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)] backdrop-blur">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
面板说明
</p>
<div className="mt-4 grid gap-4 sm:grid-cols-3">
<div className="rounded-[24px] bg-[var(--paper-soft)] p-5">
<h3 className="font-[family-name:var(--font-display)] text-xl text-[var(--ink)]">
留言桥接
</h3>
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
老人在数字人对话里说“给儿子带句话”时,模型会走 function calling,把内容存到这里。
</p>
</div>
<div className="rounded-[24px] bg-[var(--paper-soft)] p-5">
<h3 className="font-[family-name:var(--font-display)] text-xl text-[var(--ink)]">
陪伴记录
</h3>
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
面板会收下老人端的重要使用事件、最近对话片段和工具调用记录,方便您回看状态。
</p>
</div>
<div className="rounded-[24px] bg-[var(--paper-soft)] p-5">
<h3 className="font-[family-name:var(--font-display)] text-xl text-[var(--ink)]">
反向留言
</h3>
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
家属端写下的留言会进到老人设备的消息列表里,也能被 function calling 工具读取给老人听。
</p>
</div>
</div>
</div>
</section>
{dashboard.devices.length === 0 ? (
<section className="rounded-[32px] border border-dashed border-[var(--line)] bg-white/70 px-6 py-10 text-center shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
还没有绑定老人设备
</p>
<h2 className="mt-3 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
先扫一下老人手机里的二维码
</h2>
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
绑定完成后,这里会出现老人的留言、陪伴轨迹、最近对话和工具调用记录。当前页面已经替您准备好扫码与手动绑定入口。
</p>
</section>
) : (
<section className="space-y-6">
{dashboard.devices.map((binding) => {
const familyMessages = binding.elderDevice.messages.filter(
(message) => message.direction === "FAMILY_TO_ELDER",
);
const elderMessages = binding.elderDevice.messages.filter(
(message) => message.direction === "ELDER_TO_FAMILY",
);
return (
<article
key={binding.id}
className="rounded-[36px] border border-white/75 bg-white/80 p-6 shadow-[0_28px_70px_rgba(108,73,41,0.12)] backdrop-blur lg:p-7"
>
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
老人设备
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
{binding.elderDevice.displayName || "未命名陪伴设备"}
</h2>
<div className="mt-3 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
UUID:{binding.elderDevice.deviceUuid}
</span>
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
最近在线:{formatTime(binding.elderDevice.lastSeenAt)}
</span>
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
绑定链接:{binding.bindUrl}
</span>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-4">
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
<p className="text-xs text-[var(--muted)]">老人新留言</p>
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
{binding.elderUnreadCount}
</p>
</div>
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
<p className="text-xs text-[var(--muted)]">待老人查看</p>
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
{binding.familyUnreadCount}
</p>
</div>
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
<p className="text-xs text-[var(--muted)]">对话条数</p>
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
{binding.elderDevice._count.conversationTurns}
</p>
</div>
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
<p className="text-xs text-[var(--muted)]">工具调用</p>
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
{binding.elderDevice._count.toolCalls}
</p>
</div>
</div>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<div className="space-y-6">
<section className="rounded-[28px] bg-[var(--paper-soft)] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium tracking-[0.16em] text-[var(--copper)] uppercase">
老人给家里留的话
</p>
<h3 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
最近想转达给家人的内容
</h3>
</div>
<span className="rounded-full bg-white px-4 py-2 text-sm text-[var(--ink)]">
未读 {binding.elderUnreadCount}
</span>
</div>
<div className="mt-4 space-y-3">
{elderMessages.length === 0 ? (
<p className="rounded-[22px] bg-white/80 px-4 py-4 text-sm leading-7 text-[var(--muted)]">
老人暂时还没有通过大模型给家里捎话。
</p>
) : (
elderMessages.map((message) => (
<div key={message.id} className="rounded-[22px] bg-white/88 px-4 py-4">
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
{getImportanceLabel(message.importance)}
</span>
{message.recipientRelation ? (
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
目标:{message.recipientRelation}
</span>
) : null}
<span>{formatTime(message.createdAt)}</span>
</div>
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
{message.content}
</p>
</div>
))
)}
</div>
</section>
<section className="rounded-[28px] bg-[var(--paper-soft)] p-5">
<p className="text-sm font-medium tracking-[0.16em] text-[var(--copper)] uppercase">
最近活动
</p>
<h3 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
老人端刚刚发生了什么
</h3>
<div className="mt-4 space-y-3">
{binding.elderDevice.usageEvents.length === 0 ? (
<p className="rounded-[22px] bg-white/80 px-4 py-4 text-sm leading-7 text-[var(--muted)]">
暂时还没有同步到新的使用记录。
</p>
) : (
binding.elderDevice.usageEvents.map((event) => (
<div key={event.id} className="rounded-[22px] bg-white/88 px-4 py-4">
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
<span>{getUsageLabel(event.eventType)}</span>
<span>{formatTime(event.createdAt)}</span>
</div>
</div>
))
)}
</div>
</section>
</div>
<div className="space-y-6">
<SendMessageForm deviceUuid={binding.elderDevice.deviceUuid} />
<section className="rounded-[28px] bg-[var(--paper-soft)] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium tracking-[0.16em] text-[var(--copper)] uppercase">
发给老人的留言
</p>
<h3 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
最近写给老人的问候
</h3>
</div>
<span className="rounded-full bg-white px-4 py-2 text-sm text-[var(--ink)]">
待查看 {binding.familyUnreadCount}
</span>
</div>
<div className="mt-4 space-y-3">
{familyMessages.length === 0 ? (
<p className="rounded-[22px] bg-white/80 px-4 py-4 text-sm leading-7 text-[var(--muted)]">
您还没有给这位老人留下新的问候。
</p>
) : (
familyMessages.map((message) => (
<div key={message.id} className="rounded-[22px] bg-white/88 px-4 py-4">
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
{getImportanceLabel(message.importance)}
</span>
<span>{formatTime(message.createdAt)}</span>
<span>
{message.readAt ? "老人已看过" : "等待老人查看"}
</span>
</div>
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
{message.content}
</p>
</div>
))
)}
</div>
</section>
<section className="rounded-[28px] bg-[var(--paper-soft)] p-5">
<p className="text-sm font-medium tracking-[0.16em] text-[var(--copper)] uppercase">
最近对话片段
</p>
<h3 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
可快速回看的陪伴内容
</h3>
<div className="mt-4 space-y-3">
{binding.elderDevice.conversationTurns.length === 0 ? (
<p className="rounded-[22px] bg-white/80 px-4 py-4 text-sm leading-7 text-[var(--muted)]">
这位老人最近的实时对话还没有同步到这里。
</p>
) : (
binding.elderDevice.conversationTurns.map((turn) => (
<div key={turn.id} className="rounded-[22px] bg-white/88 px-4 py-4">
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
<span>
{turn.role === "USER"
? "老人说"
: turn.role === "ASSISTANT"
? "数字人回复"
: "工具结果"}
</span>
<span>{formatTime(turn.createdAt)}</span>
</div>
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
{turn.content}
</p>
</div>
))
)}
</div>
</section>
</div>
</div>
</article>
);
})}
</section>
)}
</div>
</main>
);
}
+15 -32
View File
@@ -1,40 +1,23 @@
import Link from "next/link"; import Link from "next/link";
import { PanelShell } from "@/components/panel-shell";
import { ScanClient } from "@/components/scan-client"; import { ScanClient } from "@/components/scan-client";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export default function ScanPage() { export const dynamic = "force-dynamic";
export default async function ScanPage() {
const caregiver = await requireCaregiverSession("/scan");
return ( return (
<main className="mx-auto min-h-screen max-w-5xl px-4 py-8 sm:px-6 lg:px-8"> <PanelShell currentPath="/scan" title="扫码绑定" caregiverLabel={getCaregiverDisplayName(caregiver)} backHref="/devices">
<section className="grid gap-6 lg:grid-cols-[0.82fr_1.18fr]"> <div className="scan-panel"><ScanClient /></div>
<div className="rounded-[36px] border border-white/70 bg-white/78 p-6 shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur"> <section className="card card-pad" style={{ color: "var(--muted)", fontSize: 12.5, lineHeight: 1.7 }}>
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase"> <p style={{ margin: 0 }}>1. 让长辈打开陪伴屏上的「家人连接」页面。</p>
扫码绑定 <p style={{ margin: "4px 0 0" }}>2. 将镜头对准屏幕上的二维码,保持一两秒。</p>
</p> <p style={{ margin: "4px 0 0" }}>3. 识别成功后自动完成绑定并返回设备页。</p>
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl leading-[1.15] text-[var(--ink)]">
对准老人手机里的家人连接二维码
</h1>
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
扫描成功后,系统会自动把老人的本机 UUID 绑定到当前家属账号。之后,您就能看见老人的留言、最近对话和使用记录。
</p>
<div className="mt-6 space-y-3 rounded-[28px] bg-[var(--paper-soft)] p-5 text-sm leading-7 text-[var(--muted)]">
<p>1. 让老人打开安卓端的家人连接页。</p>
<p>2. 把镜头对准屏幕上的二维码,停稳一两秒。</p>
<p>3. 绑定成功后会自动跳回控制台。</p>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/"
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
>
回到控制台
</Link>
</div>
</div>
<ScanClient />
</section> </section>
</main> <Link href="/devices" className="btn block">没有摄像头?改用设备码连接</Link>
</PanelShell>
); );
} }
+38
View File
@@ -0,0 +1,38 @@
import { ChevronRight } from "lucide-react";
import { PanelShell } from "@/components/panel-shell";
import { PwaControls } from "@/components/pwa-controls";
import { getCaregiverSettingsOverview } from "@/lib/caregiver-panel";
import { requireCaregiverSession } from "@/lib/page-auth";
import { getCaregiverDisplayName } from "@/lib/session";
export const dynamic = "force-dynamic";
export default async function SettingsPage() {
const caregiver = await requireCaregiverSession("/settings");
const overview = await getCaregiverSettingsOverview(caregiver.id);
const name = getCaregiverDisplayName(caregiver);
return (
<PanelShell currentPath="/settings" title="我的" description="账号、通知与设备" caregiverLabel={name} caregiverId={caregiver.id}>
<section className="card settings-profile">
<span className="avatar">{name.slice(-1)}</span>
<span className="row-main">
<strong style={{ display: "block", fontSize: 15.5 }}>{name}</strong>
<span className="row-meta">已绑定 {overview.deviceCount} 台设备 · {overview.pushSubscriptionCount} 台设备已开启提醒</span>
</span>
<ChevronRight className="chevron" size={16} strokeWidth={2} />
</section>
<section>
<div className="section-title"><h2>通知与设备</h2></div>
<PwaControls deviceCount={overview.deviceCount} />
</section>
<form action="/api/account/logout" method="post" className="card">
<button type="submit" className="settings-row" style={{ justifyContent: "center", color: "var(--danger)", fontSize: 14, fontWeight: 600 }}>退出登录</button>
</form>
<p className="muted-note" style={{ textAlign: "center" }}>安智伴 · AI 数字人陪护 · 家属端 v2</p>
</PanelShell>
);
}
+548 -569
View File
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
"use client";
import { useRouter } from "next/navigation";
import { startTransition, useState } from "react";
import { useNavigationProgress } from "@/components/navigation-progress";
type AccountAuthFormProps = {
redirectTo: string;
};
type AuthMode = "login" | "register";
export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) {
const router = useRouter();
const { beginNavigationProgress } = useNavigationProgress();
const [mode, setMode] = useState<AuthMode>("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!username.trim()) {
setNotice("请先输入用户名。");
return;
}
if (!password) {
setNotice("请先输入密码。");
return;
}
if (mode === "register" && password !== confirmPassword) {
setNotice("两次输入的密码还不一致,请再检查一次。");
return;
}
setSubmitting(true);
setNotice(mode === "login" ? "正在登录账号……" : "正在创建账号……");
try {
const response = await fetch(
mode === "login" ? "/api/account/login" : "/api/account/register",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: username.trim(),
password,
redirectTo,
}),
},
);
const payload = (await response.json().catch(() => null)) as
| { error?: string; redirectTo?: string }
| null;
if (!response.ok) {
throw new Error(
payload?.error ||
(mode === "login"
? "登录失败,请稍后再试。"
: "创建账号失败,请稍后再试。"),
);
}
setNotice(mode === "login" ? "登录成功,正在进入……" : "账号已创建,正在进入……");
startTransition(() => {
beginNavigationProgress();
router.replace(payload?.redirectTo || redirectTo);
router.refresh();
});
} catch (error) {
setNotice(
error instanceof Error ? error.message : "操作失败,请稍后再试。",
);
} finally {
setSubmitting(false);
}
}
return (
<div>
<div className="segment">
{[
{ id: "login", label: "已有账号,直接登录" },
{ id: "register", label: "第一次使用,创建账号" },
].map((item) => {
const active = mode === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => {
setMode(item.id as AuthMode);
setNotice(null);
}}
className={active ? "active" : ""}
>
{item.label}
</button>
);
})}
</div>
<form style={{ display: "flex", flexDirection: "column", gap: 10, marginTop: 12 }} onSubmit={handleSubmit}>
<div>
<label className="sr-only" htmlFor="caregiver-username">用户名</label>
<input
id="caregiver-username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
placeholder="用户名"
className="field"
/>
</div>
<div>
<label className="sr-only" htmlFor="caregiver-password">密码</label>
<input
id="caregiver-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete={mode === "login" ? "current-password" : "new-password"}
placeholder="请输入密码"
className="field"
/>
</div>
{mode === "register" ? (
<div>
<label className="sr-only" htmlFor="caregiver-password-confirm">再输入一次密码</label>
<input
id="caregiver-password-confirm"
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
placeholder="再次输入密码"
className="field"
/>
</div>
) : null}
<button
type="submit"
disabled={submitting}
className="btn primary block"
style={{ minHeight: 44 }}
>
{submitting
? mode === "login"
? "正在登录"
: "正在创建账号"
: mode === "login"
? "登录并继续"
: "创建账号并继续"}
</button>
</form>
{notice ? (
<p style={{ margin: "10px 0 0", borderRadius: 8, background: "var(--surface)", padding: "10px 12px", color: "var(--muted)", fontSize: 12, lineHeight: 1.6 }}>
{notice}
</p>
) : null}
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
} from "recharts";
type DailyCount = {
date: string;
count: number;
};
type ActivityChartProps = {
data: DailyCount[];
color?: string;
};
export function ActivityChart({ data, color = "var(--copper)" }: ActivityChartProps) {
if (data.length === 0) {
return null;
}
return (
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(85,60,42,0.08)" />
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "#726153" }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: "#726153" }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
borderRadius: 16,
border: "1px solid rgba(85,60,42,0.12)",
background: "rgba(255,253,248,0.96)",
fontSize: 13,
}}
labelStyle={{ color: "#241b14", fontWeight: 600 }}
formatter={(value) => [`${Number(value ?? 0)} 次`, "次数"]}
/>
<Bar
dataKey="count"
fill={color}
radius={[6, 6, 0, 0]}
maxBarSize={32}
/>
</BarChart>
</ResponsiveContainer>
</div>
);
}
+201 -78
View File
@@ -2,10 +2,13 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { startTransition, useState } from "react"; import { startTransition, useRef, useState } from "react";
import { useNavigationProgress } from "@/components/navigation-progress";
export function BindDeviceForm() { export function BindDeviceForm() {
const router = useRouter(); const router = useRouter();
const { beginNavigationProgress } = useNavigationProgress();
const [rawCode, setRawCode] = useState(""); const [rawCode, setRawCode] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
@@ -14,84 +17,42 @@ export function BindDeviceForm() {
event.preventDefault(); event.preventDefault();
if (!rawCode.trim()) { if (!rawCode.trim()) {
setNotice("先把老人二维码里的设备码贴进来。\n"); setNotice("请先粘贴长辈手机上的设备码。\n");
return; return;
} }
setSubmitting(true); setSubmitting(true);
setNotice("正在建立连接,请稍候……"); setNotice("正在前往绑定页面,请稍候……");
try {
const response = await fetch("/api/family/bind", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ deviceUuid: rawCode }),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string }
| null;
if (!response.ok) {
throw new Error(payload?.error || "连接失败,请稍后再试。");
}
setNotice("已经连上了,这位老人的留言和陪伴记录会出现在下方。\n");
setRawCode("");
startTransition(() => { startTransition(() => {
router.refresh(); beginNavigationProgress();
router.push(`/bind?deviceUuid=${encodeURIComponent(rawCode.trim())}`);
}); });
} catch (error) {
setNotice(
error instanceof Error ? error.message : "连接失败,请稍后再试。",
);
} finally {
setSubmitting(false);
}
} }
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="粘贴二维码链接,或者直接贴设备 UUID" 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}
@@ -109,12 +70,14 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
const [importance, setImportance] = useState("NORMAL"); const [importance, setImportance] = useState("NORMAL");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [createdMessagePublicId, setCreatedMessagePublicId] = useState<number | null>(null);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!content.trim()) { if (!content.trim()) {
setNotice("先写下想对老人说的话。\n"); setNotice("请先写下您想说的话。\n");
setCreatedMessagePublicId(null);
return; return;
} }
@@ -135,20 +98,24 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
}); });
const payload = (await response.json().catch(() => null)) as const payload = (await response.json().catch(() => null)) as
| { error?: string } | { error?: string; publicId?: number }
| null; | null;
if (!response.ok) { if (!response.ok) {
throw new Error(payload?.error || "留言发送失败,请稍后再试。"); throw new Error(payload?.error || "留言发送失败,请稍后再试。");
} }
setNotice("留言已经写进家人的收件盒,老人下次打开或询问时就能看到。\n"); setNotice("留言已发出,长辈下次使用时就能看到。\n");
setCreatedMessagePublicId(
typeof payload?.publicId === "number" ? payload.publicId : null,
);
setContent(""); setContent("");
setImportance("NORMAL"); setImportance("NORMAL");
startTransition(() => { startTransition(() => {
router.refresh(); router.refresh();
}); });
} catch (error) { } catch (error) {
setCreatedMessagePublicId(null);
setNotice( setNotice(
error instanceof Error ? error.message : "留言发送失败,请稍后再试。", error instanceof Error ? error.message : "留言发送失败,请稍后再试。",
); );
@@ -158,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>
@@ -185,27 +146,189 @@ 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)]">
这条话会进入老人的留言列表,也会被 function calling 工具读取到。
</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 ? (
<div style={{ marginTop: 9, color: "var(--success)", fontSize: 12, lineHeight: 1.6 }}>
<p className="whitespace-pre-line">{notice}</p>
{createdMessagePublicId ? (
<Link
href={`/messages/${createdMessagePublicId}`}
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)]"
>
打开留言 #{createdMessagePublicId}
</Link>
) : null}
</div>
) : null}
</form>
);
}
type ManageDeviceFormProps = {
deviceUuid: string;
initialAlias?: string | null;
deviceLabel: string;
};
export function ManageDeviceForm({
deviceUuid,
initialAlias,
deviceLabel,
}: ManageDeviceFormProps) {
const router = useRouter();
const { beginNavigationProgress } = useNavigationProgress();
const aliasInputRef = useRef<HTMLInputElement | null>(null);
const [saving, setSaving] = useState(false);
const [unbinding, setUnbinding] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
async function handleSaveAlias(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const alias = aliasInputRef.current?.value ?? "";
setSaving(true);
setNotice("正在保存设备别名……");
try {
const response = await fetch("/api/family/device-binding", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
deviceUuid,
alias,
}),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string; alias?: string | null; deviceName?: string }
| null;
if (!response.ok) {
throw new Error(payload?.error || "设备别名保存失败。");
}
if (aliasInputRef.current) {
aliasInputRef.current.value = payload?.alias?.trim() || "";
}
setNotice(
payload?.alias?.trim()
? `已将这台设备备注为“${payload.alias.trim()}”。\n`
: "已恢复默认设备名称。\n",
);
startTransition(() => {
router.refresh();
});
} catch (error) {
setNotice(
error instanceof Error ? error.message : "设备别名保存失败。",
);
} finally {
setSaving(false);
}
}
async function handleUnbind() {
if (!window.confirm(`解绑后将不再收到“${deviceLabel}”的留言和推送提醒,确定继续吗?`)) {
return;
}
setUnbinding(true);
setNotice("正在解绑这台设备……");
try {
const response = await fetch("/api/family/device-binding", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
deviceUuid,
}),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string }
| null;
if (!response.ok) {
throw new Error(payload?.error || "解绑设备失败。",);
}
startTransition(() => {
beginNavigationProgress();
router.push("/devices");
router.refresh();
});
} catch (error) {
setNotice(
error instanceof Error ? error.message : "解绑设备失败。",
);
setUnbinding(false);
}
}
return (
<section className="rounded-[28px] border border-white/70 bg-white/88 p-5">
<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>
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
设备别名会显示在设备列表、留言页和推送通知里,例如“外婆的手机”。
</p>
<form className="mt-4 flex flex-col gap-3 sm:flex-row" onSubmit={handleSaveAlias}>
<input
key={`${deviceUuid}:${initialAlias ?? ""}`}
ref={aliasInputRef}
defaultValue={initialAlias ?? ""}
maxLength={24}
placeholder="给这台设备起个好记的名字"
className="h-12 flex-1 rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
/>
<button
type="submit"
disabled={saving || unbinding}
className="inline-flex h-12 min-w-32 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"
>
{saving ? "正在保存" : "保存别名"}
</button>
</form>
<div className="mt-4 flex flex-wrap gap-3">
<button
type="button"
onClick={() => {
void handleUnbind();
}}
disabled={saving || unbinding}
className="inline-flex h-11 items-center justify-center rounded-full border border-[rgba(160,74,48,0.28)] bg-white px-4 text-sm font-semibold text-[var(--ink)] transition hover:bg-[rgba(199,103,51,0.08)] disabled:cursor-not-allowed disabled:opacity-60"
>
{unbinding ? "正在解绑" : "解绑这台设备"}
</button>
</div>
{notice ? ( {notice ? (
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-[var(--muted)]"> <p className="mt-3 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
{notice} {notice}
</p> </p>
) : null} ) : null}
</form> </section>
); );
} }
+350
View File
@@ -0,0 +1,350 @@
"use client";
import { ShieldCheck } from "lucide-react";
import { useRouter } from "next/navigation";
import { type FormEvent, useState } from "react";
async function api(path: string, method: string, body: Record<string, unknown>) {
const response = await fetch(path, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || "操作失败,请稍后再试。");
return payload;
}
function Notice({ text }: { text: string }) {
return text ? <p className="form-notice">{text}</p> : null;
}
export function FamilyPostForm({ deviceUuid }: { deviceUuid: string }) {
const router = useRouter();
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setBusy(true);
const form = new FormData(event.currentTarget);
try {
await api("/api/family-feed", "POST", {
deviceUuid,
content: form.get("content"),
visibility: form.get("visibility"),
});
event.currentTarget.reset();
setNotice("已发布到家庭动态。");
router.refresh();
} catch (error) {
setNotice(error instanceof Error ? error.message : "发布失败。");
} finally {
setBusy(false);
}
}
return (
<form className="card card-pad" onSubmit={submit}>
<label className="field-label" htmlFor="family-post-content">发一条家庭动态</label>
<textarea id="family-post-content" className="field" name="content" required maxLength={1000} placeholder="分享一句问候,或和家人同步今天的情况……" style={{ minHeight: 88 }} />
<div className="form-grid publish" style={{ marginTop: 10 }}>
<select className="field" name="visibility" defaultValue="FAMILY_AND_ELDER" aria-label="动态可见范围">
<option value="FAMILY_AND_ELDER">老人和全家可见</option>
<option value="ELDER_ONLY">只发给老人</option>
<option value="FAMILY_ONLY">仅家庭内部</option>
</select>
<button className="btn primary" disabled={busy}>{busy ? "发布中" : "发布"}</button>
</div>
<Notice text={notice} />
</form>
);
}
export function MemoForm({ deviceUuid }: { deviceUuid: string }) {
const router = useRouter();
const [notice, setNotice] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
await api("/api/memos", "POST", {
deviceUuid,
title: form.get("title"),
content: form.get("content"),
priority: form.get("priority"),
visibility: form.get("visibility"),
});
event.currentTarget.reset();
setNotice("家庭备忘已保存。");
router.refresh();
} catch (error) {
setNotice(error instanceof Error ? error.message : "保存失败。");
}
}
return (
<form className="card card-pad" onSubmit={submit}>
<label className="field-label" htmlFor="memo-title">新建家庭备忘</label>
<div className="form-stack">
<input id="memo-title" className="field" name="title" required maxLength={80} placeholder="备忘标题,如:医保卡位置" />
<textarea className="field" name="content" required maxLength={1000} placeholder="写下家人需要共同记住的信息" />
<div className="form-grid memo-actions">
<select className="field" name="priority" aria-label="备忘优先级">
<option value="NORMAL">普通</option>
<option value="IMPORTANT">重要</option>
<option value="PINNED">长期置顶</option>
</select>
<select className="field" name="visibility" aria-label="备忘可见范围">
<option value="FAMILY_AND_ELDER">可告诉老人</option>
<option value="FAMILY_ONLY">仅家人可见</option>
</select>
<button className="btn primary">保存</button>
</div>
</div>
<Notice text={notice} />
</form>
);
}
export function TaskForm({ deviceUuid }: { deviceUuid: string }) {
const router = useRouter();
const [notice, setNotice] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
await api("/api/care-tasks", "POST", {
deviceUuid,
title: form.get("title"),
description: form.get("description"),
dueAt: form.get("dueAt"),
});
event.currentTarget.reset();
setNotice("照护任务已创建,等待家人认领。");
router.refresh();
} catch (error) {
setNotice(error instanceof Error ? error.message : "创建失败。");
}
}
return (
<form className="card card-pad" onSubmit={submit}>
<label className="field-label" htmlFor="task-title">新建照护任务</label>
<div className="form-stack">
<input id="task-title" className="field" name="title" required maxLength={100} placeholder="任务,如:陪爷爷周三复诊" />
<input className="field" name="description" maxLength={500} placeholder="补充说明(可选)" />
<div className="form-grid action">
<input className="field" name="dueAt" type="datetime-local" aria-label="任务期限" />
<button className="btn primary">创建任务</button>
</div>
</div>
<Notice text={notice} />
</form>
);
}
export function TaskActions({ taskId, status }: { taskId: string; status: string }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function act(action: string) {
setBusy(true);
try {
let note: string | undefined;
if (action === "complete") note = window.prompt("补充完成说明(可留空)") || undefined;
await api("/api/care-tasks", "PATCH", { taskId, action, note });
router.refresh();
} finally {
setBusy(false);
}
}
if (status === "COMPLETED") return <span className="tag success" style={{ marginTop: 9 }}>已完成</span>;
return (
<div className="button-row">
<button className="btn small" disabled={busy} onClick={() => void act("claim")}>{status === "CLAIMED" ? "接手人" : "我来处理"}</button>
<button className="btn primary small" disabled={busy} onClick={() => void act("complete")}>完成</button>
</div>
);
}
export function MedicationForm({ deviceUuid }: { deviceUuid: string }) {
const router = useRouter();
const [notice, setNotice] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
await api("/api/medications", "POST", {
deviceUuid,
name: form.get("name"),
dosage: form.get("dosage"),
times: form.get("times"),
instructions: form.get("instructions"),
source: form.get("source"),
});
event.currentTarget.reset();
setNotice("未来 7 天的用药提醒已建立。");
router.refresh();
} catch (error) {
setNotice(error instanceof Error ? error.message : "创建失败。");
}
}
return (
<form className="card card-pad" onSubmit={submit}>
<label className="field-label" htmlFor="medication-name">新建用药提醒</label>
<div className="form-stack">
<div className="form-grid two">
<input id="medication-name" className="field" name="name" required placeholder="药品名称" />
<input className="field" name="dosage" required placeholder="单次用量,如 1 片" />
</div>
<div className="form-grid two">
<input className="field" name="times" required placeholder="时间,如 08:00, 20:00" />
<select className="field" name="source" aria-label="用药信息来源">
<option value="PRESCRIPTION">医生处方</option>
<option value="PACKAGE_LABEL">药盒标签</option>
<option value="FAMILY_ENTRY">家属录入</option>
</select>
</div>
<input className="field" name="instructions" placeholder="饭前 / 饭后及其他注意事项(可选)" />
<button className="btn primary block">建立未来 7 天提醒</button>
</div>
<Notice text={notice} />
</form>
);
}
export function CheckInButton({ deviceUuid }: { deviceUuid: string }) {
const router = useRouter();
const [notice, setNotice] = useState("");
async function create() {
try {
await api("/api/check-ins", "POST", { deviceUuid, prompt: "家里人想知道您今天是否安好。" });
setNotice("报平安请求已送到长辈终端,回应后会通知全家。");
router.refresh();
} catch (error) {
setNotice(error instanceof Error ? error.message : "发送失败。");
}
}
return (
<div className="checkin-action">
<button className="btn primary block" onClick={() => void create()}><ShieldCheck size={16} strokeWidth={2} />请长辈报个平安</button>
<Notice text={notice} />
</div>
);
}
export function AlertActions({ alertId, status }: { alertId: string; status: string }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function act(action: string) {
setBusy(true);
try {
let note;
if (action !== "claim") {
note = window.prompt(action === "resolve" ? "请填写处理结果" : "请填写忽略原因") || "";
if (!note && action === "resolve") return;
}
await api("/api/alerts", "PATCH", { alertId, action, note });
router.refresh();
} finally {
setBusy(false);
}
}
if (status === "RESOLVED" || status === "DISMISSED") return <span className="tag success" style={{ marginTop: 9 }}>已处理</span>;
return (
<div className="button-row">
<button className="btn small" disabled={busy} onClick={() => void act("claim")}>我来处理</button>
<button className="btn primary small" disabled={busy} onClick={() => void act("resolve")}>填写结果</button>
<button className="btn small muted" disabled={busy} onClick={() => void act("dismiss")}>误报</button>
</div>
);
}
export function InviteMemberForm({ deviceUuid }: { deviceUuid: string }) {
const [url, setUrl] = useState("");
const [notice, setNotice] = useState("");
const [copied, setCopied] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
const result = await api("/api/family-circle/invite", "POST", {
deviceUuid,
relationLabel: form.get("relationLabel"),
role: form.get("role"),
});
setUrl(result.inviteUrl);
setCopied(false);
setNotice("邀请链接 24 小时内有效,发给家人即可加入。");
} catch (error) {
setNotice(error instanceof Error ? error.message : "邀请失败。");
}
}
async function copyInvite() {
await navigator.clipboard.writeText(url);
setCopied(true);
}
return (
<form className="card card-pad" onSubmit={submit}>
<div className="form-grid two">
<span>
<label className="field-label" htmlFor="invite-relation">与长辈的关系</label>
<input id="invite-relation" className="field" name="relationLabel" placeholder="如:儿子" />
</span>
<span>
<label className="field-label" htmlFor="invite-role">成员角色</label>
<select id="invite-role" className="field" name="role">
<option value="CAREGIVER">照护成员</option>
<option value="CARING">关怀成员</option>
</select>
</span>
</div>
<button className="btn dark block" style={{ marginTop: 10 }}>生成邀请链接</button>
{url ? (
<div className="form-grid" style={{ gridTemplateColumns: "minmax(0, 1fr) 68px", marginTop: 10 }}>
<input className="field" readOnly value={url} style={{ height: 40, background: "var(--surface-soft)", color: "var(--muted)", fontSize: 12 }} />
<button type="button" className="btn" style={{ minHeight: 40, padding: 0 }} onClick={() => void copyInvite()}>{copied ? "已复制" : "复制"}</button>
</div>
) : null}
<Notice text={notice} />
</form>
);
}
export function JoinFamilyButton({ token }: { token: string }) {
const router = useRouter();
const [notice, setNotice] = useState("");
async function join() {
try {
await api("/api/family-circle/join", "POST", { token });
setNotice("已加入家庭守护圈,正在打开家庭页……");
router.push("/family");
router.refresh();
} catch (error) {
setNotice(error instanceof Error ? error.message : "加入失败。");
}
}
return (
<div>
<button className="btn primary block" onClick={() => void join()}>接受邀请并加入</button>
<Notice text={notice} />
</div>
);
}
+217
View File
@@ -0,0 +1,217 @@
"use client";
import {
createContext,
useContext,
useCallback,
useEffect,
useMemo,
useRef,
useState,
Suspense,
type MutableRefObject,
type ReactNode,
} from "react";
import { usePathname, useSearchParams } from "next/navigation";
const SHOW_DELAY_MS = 120;
const MIN_VISIBLE_MS = 180;
const FAILSAFE_MS = 12000;
type NavigationProgressContextValue = {
beginNavigationProgress: () => void;
};
const NavigationProgressContext = createContext<NavigationProgressContextValue | null>(
null,
);
function clearTimer(timerRef: MutableRefObject<number | null>) {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}
function NavigationProgressComplete({ onComplete }: { onComplete: () => void }) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
onComplete();
}, [pathname, searchParams, onComplete]);
return null;
}
export function NavigationProgressProvider({
children,
}: {
children: ReactNode;
}) {
const [visible, setVisible] = useState(false);
const visibleRef = useRef(false);
const shownAtRef = useRef(0);
const showTimerRef = useRef<number | null>(null);
const hideTimerRef = useRef<number | null>(null);
const failsafeTimerRef = useRef<number | null>(null);
const setProgressVisible = useCallback((nextVisible: boolean) => {
visibleRef.current = nextVisible;
setVisible(nextVisible);
if (nextVisible) {
shownAtRef.current = Date.now();
}
}, []);
const beginNavigationProgress = useCallback(() => {
clearTimer(hideTimerRef);
clearTimer(failsafeTimerRef);
if (visibleRef.current) {
failsafeTimerRef.current = window.setTimeout(() => {
setProgressVisible(false);
}, FAILSAFE_MS);
return;
}
clearTimer(showTimerRef);
showTimerRef.current = window.setTimeout(() => {
setProgressVisible(true);
failsafeTimerRef.current = window.setTimeout(() => {
setProgressVisible(false);
}, FAILSAFE_MS);
}, SHOW_DELAY_MS);
}, [setProgressVisible]);
const endNavigationProgress = useCallback(() => {
clearTimer(showTimerRef);
clearTimer(failsafeTimerRef);
if (!visibleRef.current) {
return;
}
const remainingVisibleMs = Math.max(
0,
MIN_VISIBLE_MS - (Date.now() - shownAtRef.current),
);
clearTimer(hideTimerRef);
hideTimerRef.current = window.setTimeout(() => {
setProgressVisible(false);
}, remainingVisibleMs);
}, [setProgressVisible]);
useEffect(() => {
function handleDocumentClick(event: MouseEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const anchor = target.closest("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
const href = anchor.getAttribute("href");
if (!href || href.startsWith("#") || anchor.hasAttribute("download")) {
return;
}
if (anchor.target && anchor.target !== "_self") {
return;
}
const nextUrl = new URL(anchor.href, window.location.href);
const currentUrl = new URL(window.location.href);
if (nextUrl.origin !== currentUrl.origin) {
return;
}
if (
nextUrl.pathname === currentUrl.pathname &&
nextUrl.search === currentUrl.search
) {
return;
}
beginNavigationProgress();
}
window.addEventListener("click", handleDocumentClick, true);
return () => {
window.removeEventListener("click", handleDocumentClick, true);
};
}, [beginNavigationProgress]);
useEffect(() => {
function handlePopState() {
beginNavigationProgress();
}
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}, [beginNavigationProgress]);
useEffect(() => {
return () => {
clearTimer(showTimerRef);
clearTimer(hideTimerRef);
clearTimer(failsafeTimerRef);
};
}, []);
const contextValue = useMemo(
() => ({ beginNavigationProgress }),
[beginNavigationProgress],
);
return (
<NavigationProgressContext.Provider value={contextValue}>
{children}
<Suspense fallback={null}>
<NavigationProgressComplete onComplete={endNavigationProgress} />
</Suspense>
<div
aria-live="polite"
className={`pointer-events-none fixed inset-x-0 top-0 z-[100] transition-opacity duration-150 ${
visible ? "opacity-100" : "opacity-0"
}`}
>
<div aria-hidden="true" className="h-1 w-full bg-[rgba(36,27,20,0.08)]">
<div className="route-progress-bar h-full w-2/5 rounded-full bg-[linear-gradient(90deg,rgba(53,84,141,0.2),rgba(199,103,51,0.95),rgba(53,84,141,0.75))]" />
</div>
{visible ? <span className="sr-only">页面正在加载</span> : null}
</div>
</NavigationProgressContext.Provider>
);
}
export function useNavigationProgress() {
const context = useContext(NavigationProgressContext);
if (!context) {
throw new Error("useNavigationProgress 必须在 NavigationProgressProvider 内使用。");
}
return context;
}
+94
View File
@@ -0,0 +1,94 @@
import Link from "next/link";
import { ChevronLeft, HeartHandshake, House, ShieldCheck, Stethoscope, UserRound } from "lucide-react";
import type { ReactNode } from "react";
import { getCaregiverUnreadIncomingCount } from "@/lib/caregiver-panel";
const navigationItems = [
{ href: "/guard", label: "守护", icon: ShieldCheck },
{ href: "/family", label: "家庭", icon: House },
{ href: "/care", label: "照护", icon: Stethoscope },
{ href: "/companion", label: "陪伴", icon: HeartHandshake },
{ href: "/settings", label: "我的", icon: UserRound },
];
function isActivePath(currentPath: string, href: string) {
return currentPath === href || currentPath.startsWith(`${href}/`);
}
type PanelShellProps = {
currentPath: string;
title: string;
description?: string;
caregiverLabel: string;
caregiverId?: string;
children: ReactNode;
actions?: ReactNode;
backHref?: string;
showTabs?: boolean;
unreadCount?: number;
};
export async function PanelShell({
currentPath,
title,
description,
caregiverLabel,
caregiverId,
children,
actions,
backHref,
showTabs = !backHref,
unreadCount,
}: PanelShellProps) {
const initial = caregiverLabel.trim().slice(-1) || "安";
const resolvedUnreadCount = unreadCount ?? (showTabs && caregiverId
? await getCaregiverUnreadIncomingCount(caregiverId)
: 0);
return (
<main className="app-frame">
<div className={`app-content app-stack${backHref ? " detail" : ""}`}>
{backHref ? (
<header className="detail-header">
<Link href={backHref} className="back-button" aria-label="返回">
<ChevronLeft size={20} strokeWidth={2} />
</Link>
<h1>{title}</h1>
{actions}
</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}
</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 === "/family" && resolvedUnreadCount > 0 ? <span className="nav-badge">{resolvedUnreadCount}</span> : null}
</Link>
);
})}
</nav>
) : null}
</main>
);
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
type PushTestPanelProps = {
initialSubscriptionCount: number;
deviceCount: number;
};
type PushFailureDetail = {
endpointHost?: string;
statusCode?: number | null;
message?: string;
body?: string | null;
reason?: string | null;
};
export function PushTestPanel({
initialSubscriptionCount,
deviceCount,
}: PushTestPanelProps) {
const [submitting, setSubmitting] = useState(false);
const [subscriptionCount, setSubscriptionCount] = useState(
initialSubscriptionCount,
);
const [notice, setNotice] = useState<string | null>(null);
const [failureDetails, setFailureDetails] = useState<PushFailureDetail[]>([]);
async function handleTestPush() {
setSubmitting(true);
setNotice(null);
setFailureDetails([]);
try {
const response = await fetch("/api/push/test", {
method: "POST",
});
const payload = (await response.json().catch(() => null)) as
| {
error?: string;
targetedCount?: number;
sentCount?: number;
removedCount?: number;
failedCount?: number;
failureDetails?: PushFailureDetail[];
}
| null;
if (!response.ok) {
throw new Error(payload?.error || "测试推送发送失败。",);
}
const targetedCount = payload?.targetedCount || 0;
const removedCount = payload?.removedCount || 0;
const sentCount = payload?.sentCount || 0;
const failedCount = payload?.failedCount || 0;
const nextFailureDetails = Array.isArray(payload?.failureDetails)
? payload.failureDetails
: [];
setSubscriptionCount(Math.max(0, targetedCount - removedCount));
setFailureDetails(nextFailureDetails);
if (targetedCount === 0) {
setNotice("当前账号还没有任何开启消息提醒的设备,先在要接收通知的设备上打开消息提醒。\n");
return;
}
const fragments = [`已向 ${targetedCount} 台设备发出测试通知。`];
if (sentCount > 0) {
fragments.push(`成功送出 ${sentCount} 条。`);
}
if (removedCount > 0) {
fragments.push(`清理了 ${removedCount} 个失效订阅。`);
}
if (failedCount > 0) {
fragments.push(`${failedCount} 台设备本次发送失败。`);
}
setNotice(`${fragments.join(" ")}\n`);
} catch (error) {
setNotice(
error instanceof Error ? error.message : "测试推送发送失败。",
);
} finally {
setSubmitting(false);
}
}
return (
<section className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_22px_55px_rgba(53,84,141,0.12)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
推送测试
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
给当前账号的所有设备发一条测试通知
</h2>
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">
用来确认这位家属账号下已经开启提醒的设备,是否都能正常收到推送。
</p>
<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">
已连接设备 {deviceCount}
</span>
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
已开启提醒的设备 {subscriptionCount}
</span>
</div>
<div className="mt-5 flex flex-wrap gap-3">
<button
type="button"
onClick={() => {
void handleTestPush();
}}
disabled={submitting}
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)] disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? "正在发送测试通知" : "发送测试推送"}
</button>
</div>
{notice ? (
<p className="mt-4 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
{notice}
</p>
) : null}
{failureDetails.length > 0 ? (
<div className="mt-4 space-y-3">
{failureDetails.map((detail, index) => (
<div
key={`${detail.endpointHost || "unknown"}-${index}`}
className="rounded-[20px] bg-[var(--paper-soft)] px-4 py-4 text-sm leading-7 text-[var(--muted)]"
>
<p className="font-semibold text-[var(--ink)]">
{detail.endpointHost || "unknown"}
{typeof detail.statusCode === "number"
? ` · HTTP ${detail.statusCode}`
: ""}
</p>
<p className="mt-1">
{detail.reason || detail.message || "推送发送失败"}
</p>
{detail.body ? <p className="mt-1 break-all">{detail.body}</p> : null}
</div>
))}
</div>
) : null}
</section>
);
}
+248
View File
@@ -0,0 +1,248 @@
"use client";
import { Bell, ChevronRight, PenLine, Smartphone } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
const PWA_INSTALL_MARKER_KEY = "dh-pwa-installed";
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
};
type NotificationState = "checking" | "idle" | "pending" | "enabled" | "blocked" | "unsupported";
function urlBase64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let index = 0; index < rawData.length; index += 1) outputArray[index] = rawData.charCodeAt(index);
return outputArray;
}
function isStandaloneDisplayMode() {
const navigatorWithStandalone = navigator as Navigator & { standalone?: boolean };
return (
window.matchMedia("(display-mode: standalone)").matches ||
window.matchMedia("(display-mode: fullscreen)").matches ||
window.matchMedia("(display-mode: minimal-ui)").matches ||
Boolean(navigatorWithStandalone.standalone) ||
document.referrer.startsWith("android-app://")
);
}
function readInstalledMarker() {
try {
return window.localStorage.getItem(PWA_INSTALL_MARKER_KEY) === "1";
} catch {
return false;
}
}
function writeInstalledMarker() {
try {
window.localStorage.setItem(PWA_INSTALL_MARKER_KEY, "1");
} catch {
return;
}
}
export function PwaControls({ deviceCount }: { deviceCount: number }) {
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
const [installPromptEvent, setInstallPromptEvent] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
const [installing, setInstalling] = useState(false);
const [notificationState, setNotificationState] = useState<NotificationState>("checking");
const [notice, setNotice] = useState<string | null>(null);
useEffect(() => {
let active = true;
function syncInstallState() {
const standalone = isStandaloneDisplayMode();
if (standalone) writeInstalledMarker();
if (active) setIsInstalled(standalone || readInstalledMarker());
}
function handleBeforeInstallPrompt(event: Event) {
event.preventDefault();
if (active) setInstallPromptEvent(event as BeforeInstallPromptEvent);
}
function handleAppInstalled() {
writeInstalledMarker();
if (active) setIsInstalled(true);
}
async function bootstrapNotifications() {
if (!("serviceWorker" in navigator) || !("PushManager" in window) || typeof Notification === "undefined") {
setNotificationState("unsupported");
return;
}
try {
await navigator.serviceWorker.register("/sw.js");
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!active) return;
if (subscription) {
if (vapidPublicKey) {
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subscription: subscription.toJSON() }),
});
}
setNotificationState("enabled");
} else {
setNotificationState(Notification.permission === "denied" ? "blocked" : "idle");
}
} catch {
if (active) setNotificationState("unsupported");
}
}
syncInstallState();
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.addEventListener("appinstalled", handleAppInstalled);
window.addEventListener("focus", syncInstallState);
void bootstrapNotifications();
return () => {
active = false;
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.removeEventListener("appinstalled", handleAppInstalled);
window.removeEventListener("focus", syncInstallState);
};
}, [vapidPublicKey]);
async function enableNotifications() {
if (!vapidPublicKey) {
setNotice("当前还没有配置推送服务。");
return;
}
setNotificationState("pending");
setNotice(null);
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
setNotificationState(permission === "denied" ? "blocked" : "idle");
setNotice(permission === "denied" ? "浏览器已拒绝通知权限,请在系统设置中重新开启。" : "这次没有开启通知,稍后仍可再次尝试。");
return;
}
const registration = await navigator.serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subscription: subscription.toJSON() }),
});
setNotificationState("enabled");
setNotice("已开启紧急提醒推送。");
} catch {
setNotificationState("idle");
setNotice("打开消息提醒失败,请稍后再试。");
}
}
async function disableNotifications() {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await fetch("/api/push/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
await subscription.unsubscribe();
}
setNotificationState("idle");
setNotice("这台设备的紧急提醒已关闭。");
} catch {
setNotice("关闭消息提醒失败,请稍后再试。");
}
}
async function installApp() {
if (isInstalled) {
setNotice("安智伴已经添加到这台设备的主屏幕。");
return;
}
if (!installPromptEvent) {
setNotice("请从浏览器菜单中选择“安装应用”或“添加到主屏幕”。");
return;
}
setInstalling(true);
setNotice(null);
try {
await installPromptEvent.prompt();
const choice = await installPromptEvent.userChoice;
if (choice.outcome === "accepted") {
writeInstalledMarker();
setInstallPromptEvent(null);
setIsInstalled(true);
setNotice("已确认添加到主屏幕。");
}
} finally {
setInstalling(false);
}
}
const pushOn = notificationState === "enabled";
const toggleDisabled = notificationState === "checking" || notificationState === "pending" || notificationState === "unsupported";
return (
<section className="card">
<div className="settings-row">
<span className="settings-icon"><Bell size={17} strokeWidth={1.8} /></span>
<span className="row-main">
<span className="row-title">紧急提醒推送</span>
<span className="row-meta">红色风险提醒将第一时间通知本机</span>
</span>
<button
type="button"
className={`toggle${pushOn ? " on" : ""}`}
disabled={toggleDisabled}
onClick={() => void (pushOn ? disableNotifications() : enableNotifications())}
aria-label={pushOn ? "关闭紧急提醒推送" : "开启紧急提醒推送"}
aria-pressed={pushOn}
>
<span className="toggle-knob" />
</button>
</div>
<Link className="settings-row" href="/devices">
<span className="settings-icon olive"><Smartphone size={17} strokeWidth={1.8} /></span>
<span className="row-main">
<span className="row-title">设备管理</span>
<span className="row-meta">{deviceCount > 0 ? `已连接 ${deviceCount} 台长辈设备` : "绑定长辈的安智伴设备"}</span>
</span>
<ChevronRight className="chevron" size={16} strokeWidth={2} />
</Link>
<button type="button" className="settings-row" onClick={() => void installApp()} disabled={installing}>
<span className="settings-icon muted"><PenLine size={17} strokeWidth={1.8} /></span>
<span className="row-main">
<span className="row-title">添加到主屏幕</span>
<span className="row-meta">{isInstalled ? "已添加,可像 App 一样直接打开" : installing ? "正在呼起安装" : "像 App 一样从桌面直接打开"}</span>
</span>
<ChevronRight className="chevron" size={16} strokeWidth={2} />
</button>
{notice ? <p className="form-notice" style={{ margin: 0, padding: "0 16px 13px" }}>{notice}</p> : null}
</section>
);
}
+21 -33
View File
@@ -10,6 +10,8 @@ import {
useState, useState,
} from "react"; } from "react";
import { useNavigationProgress } from "@/components/navigation-progress";
function extractDeviceUuid(rawValue: string) { function extractDeviceUuid(rawValue: string) {
const trimmedValue = rawValue.trim(); const trimmedValue = rawValue.trim();
@@ -31,6 +33,7 @@ function extractDeviceUuid(rawValue: string) {
export function ScanClient() { export function ScanClient() {
const router = useRouter(); const router = useRouter();
const { beginNavigationProgress } = useNavigationProgress();
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const scannerRef = useRef<QrScanner | null>(null); const scannerRef = useRef<QrScanner | null>(null);
const [statusText, setStatusText] = useState("正在准备镜头……"); const [statusText, setStatusText] = useState("正在准备镜头……");
@@ -45,38 +48,14 @@ export function ScanClient() {
scannerRef.current.stop(); scannerRef.current.stop();
setErrorText(null); setErrorText(null);
setStatusText("识别到了设备码,正在建立连接……"); setStatusText("识别到了设备,正在打开绑定页面……");
try {
const response = await fetch("/api/family/bind", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ deviceUuid: scannedValue }),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string; deviceUuid?: string }
| null;
const deviceUuid = payload?.deviceUuid;
if (!response.ok || !deviceUuid) {
throw new Error(payload?.error || "绑定失败,请重新扫描一次。");
}
startTransition(() => { startTransition(() => {
beginNavigationProgress();
router.replace( router.replace(
`/bind?deviceUuid=${encodeURIComponent(deviceUuid)}&source=scan`, `/bind?deviceUuid=${encodeURIComponent(scannedValue)}&source=scan`,
); );
}); });
} catch (error) {
setErrorText(
error instanceof Error ? error.message : "绑定失败,请重新扫描一次。",
);
setStatusText("请再把二维码对准镜头中央。");
await scannerRef.current.start().catch(() => null);
}
}); });
useEffect(() => { useEffect(() => {
@@ -91,7 +70,7 @@ export function ScanClient() {
const hasCamera = await QrScanner.hasCamera(); const hasCamera = await QrScanner.hasCamera();
if (!hasCamera) { if (!hasCamera) {
setErrorText("当前设备没有可用摄像头,请改用系统扫码或手动粘贴设备码。"); setErrorText("当前设备没有可用摄像头,请改用手动粘贴设备码。");
setStatusText("无法打开镜头"); setStatusText("无法打开镜头");
return; return;
} }
@@ -114,7 +93,7 @@ export function ScanClient() {
await scanner.start(); await scanner.start();
if (!cancelled) { if (!cancelled) {
setStatusText("请把老人的二维码放到取景框中央。"); setStatusText("请将长辈手机上的二维码放到取景框内。");
} }
} catch (error) { } catch (error) {
setErrorText( setErrorText(
@@ -136,8 +115,18 @@ export function ScanClient() {
}, []); }, []);
return ( return (
<div className="rounded-[32px] border border-white/70 bg-white/82 p-5 shadow-[0_30px_70px_rgba(115,72,44,0.14)] backdrop-blur"> <div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<div className="relative overflow-hidden rounded-[28px] border border-[var(--line)] bg-[var(--ink)]/95"> <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-3 text-sm leading-7 text-[var(--muted)]">
保持画面稳定一两秒,识别成功后会自动继续,不需要额外点击。
</p>
<div className="relative mt-5 overflow-hidden rounded-[28px] border border-[var(--line)] bg-[var(--ink)]/95">
<video <video
ref={videoRef} ref={videoRef}
muted muted
@@ -145,13 +134,12 @@ export function ScanClient() {
className="aspect-[3/4] w-full object-cover" className="aspect-[3/4] w-full object-cover"
/> />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0_28%,rgba(10,7,5,0.48)_29%_100%)]" /> <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0_28%,rgba(10,7,5,0.48)_29%_100%)]" />
<div className="pointer-events-none absolute inset-x-[16%] top-[20%] aspect-square rounded-[28px] border-2 border-white/85 shadow-[0_0_0_999px_rgba(0,0,0,0.18)]" />
</div> </div>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
<p className="text-base font-semibold text-[var(--ink)]">{statusText}</p> <p className="text-base font-semibold text-[var(--ink)]">{statusText}</p>
<p className="text-sm leading-7 text-[var(--muted)]"> <p className="text-sm leading-7 text-[var(--muted)]">
扫到后会直接把这位老人绑定到当前家属账号,不需要额外输密码。 扫描成功后会自动连接长辈的设备,无需额外操作。
</p> </p>
{errorText ? ( {errorText ? (
<p className="rounded-[20px] bg-[var(--paper-soft)] px-4 py-3 text-sm leading-7 text-[var(--copper-deep)]"> <p className="rounded-[20px] bg-[var(--paper-soft)] px-4 py-3 text-sm leading-7 text-[var(--copper-deep)]">
+2
View File
@@ -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.
"安智伴家属端界面优化/**",
]), ]),
]); ]);
+401
View File
@@ -0,0 +1,401 @@
import { MessageDirection, MessageStatus } from "@prisma/client";
import { createBindUrl, getCaregiverDashboard } from "@/lib/monitor-data";
import { prisma } from "@/lib/prisma";
const deviceSummarySelect = {
id: true,
deviceUuid: true,
displayName: true,
appVersion: true,
lastSeenAt: true,
} as const;
async function loadCaregiverDeviceNameMap(
caregiverId: string,
elderDeviceIds: string[],
) {
if (elderDeviceIds.length === 0) {
return new Map<string, string>();
}
const bindings = await prisma.deviceBinding.findMany({
where: {
caregiverId,
elderDeviceId: {
in: elderDeviceIds,
},
},
select: {
elderDeviceId: true,
alias: true,
elderDevice: {
select: {
displayName: true,
},
},
},
});
return new Map(
bindings
.map((binding) => [
binding.elderDeviceId,
binding.alias?.trim() || binding.elderDevice.displayName?.trim() || "",
] as const)
.filter((entry) => entry[1].length > 0),
);
}
function applyCaregiverDeviceNames<
T extends {
elderDeviceId: string;
elderDevice: {
displayName: string | null;
};
},
>(items: T[], deviceNameMap: Map<string, string>) {
items.forEach((item) => {
const resolvedName = deviceNameMap.get(item.elderDeviceId);
if (resolvedName) {
item.elderDevice.displayName = resolvedName;
}
});
return items;
}
export async function getCaregiverOverview(caregiverId: string) {
const dashboard = await getCaregiverDashboard(caregiverId);
const [recentIncomingMessages, recentOutgoingMessages] = await prisma.$transaction([
prisma.familyMessage.findMany({
where: {
direction: MessageDirection.ELDER_TO_FAMILY,
elderDevice: {
bindings: {
some: { caregiverId },
},
},
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
orderBy: { createdAt: "desc" },
take: 4,
}),
prisma.familyMessage.findMany({
where: {
direction: MessageDirection.FAMILY_TO_ELDER,
caregiverId,
elderDevice: {
bindings: {
some: { caregiverId },
},
},
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
orderBy: { createdAt: "desc" },
take: 4,
}),
]);
const deviceNameMap = new Map(
dashboard.devices
.map((device) => [device.elderDeviceId, device.elderDevice.displayName?.trim() || ""] as const)
.filter((entry) => entry[1].length > 0),
);
applyCaregiverDeviceNames(recentIncomingMessages, deviceNameMap);
applyCaregiverDeviceNames(recentOutgoingMessages, deviceNameMap);
return {
dashboard,
recentIncomingMessages,
recentOutgoingMessages,
};
}
export function getCaregiverUnreadIncomingCount(caregiverId: string) {
return prisma.familyMessage.count({
where: {
direction: MessageDirection.ELDER_TO_FAMILY,
elderDevice: {
bindings: {
some: { caregiverId },
},
},
readAt: null,
},
});
}
export async function getCaregiverMessages(caregiverId: string) {
const [messages, unreadIncomingCount, unreadOutgoingCount] = await prisma.$transaction([
prisma.familyMessage.findMany({
where: {
elderDevice: {
bindings: {
some: { caregiverId },
},
},
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
orderBy: [{ createdAt: "desc" }, { publicId: "desc" }],
take: 120,
}),
getCaregiverUnreadIncomingCount(caregiverId),
prisma.familyMessage.count({
where: {
direction: MessageDirection.FAMILY_TO_ELDER,
caregiverId,
elderDevice: {
bindings: {
some: { caregiverId },
},
},
readAt: null,
},
}),
]);
const deviceNameMap = await loadCaregiverDeviceNameMap(
caregiverId,
[...new Set(messages.map((message) => message.elderDeviceId))],
);
applyCaregiverDeviceNames(messages, deviceNameMap);
return {
messages,
unreadIncomingCount,
unreadOutgoingCount,
};
}
export async function getCaregiverMessageDetail(
caregiverId: string,
publicId: number,
) {
let message = await prisma.familyMessage.findFirst({
where: {
publicId,
elderDevice: {
bindings: {
some: { caregiverId },
},
},
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
});
if (!message) {
return null;
}
if (message.direction === MessageDirection.ELDER_TO_FAMILY && !message.readAt) {
const now = new Date();
message = await prisma.familyMessage.update({
where: { id: message.id },
data: {
readAt: now,
deliveredAt: message.deliveredAt ?? now,
status: MessageStatus.READ,
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
});
}
const deviceNameMap = await loadCaregiverDeviceNameMap(caregiverId, [message.elderDeviceId]);
applyCaregiverDeviceNames([message], deviceNameMap);
return message;
}
export async function getCaregiverDevices(caregiverId: string) {
const dashboard = await getCaregiverDashboard(caregiverId);
return dashboard.devices;
}
export async function getCaregiverDeviceDetail(
caregiverId: string,
deviceUuid: string,
) {
const binding = await prisma.deviceBinding.findFirst({
where: {
caregiverId,
elderDevice: {
deviceUuid,
},
},
include: {
elderDevice: {
include: {
messages: {
orderBy: [{ createdAt: "desc" }, { publicId: "desc" }],
take: 14,
},
usageEvents: {
orderBy: { createdAt: "desc" },
take: 10,
},
conversationTurns: {
orderBy: { createdAt: "desc" },
take: 12,
},
toolCalls: {
orderBy: { createdAt: "desc" },
take: 8,
},
_count: {
select: {
messages: true,
usageEvents: true,
conversationTurns: true,
toolCalls: true,
},
},
},
},
},
});
if (!binding) {
return null;
}
const [familyUnreadCount, elderUnreadCount] = await prisma.$transaction([
prisma.familyMessage.count({
where: {
elderDeviceId: binding.elderDeviceId,
direction: MessageDirection.FAMILY_TO_ELDER,
readAt: null,
},
}),
prisma.familyMessage.count({
where: {
elderDeviceId: binding.elderDeviceId,
direction: MessageDirection.ELDER_TO_FAMILY,
readAt: null,
},
}),
]);
return {
...binding,
elderDevice: {
...binding.elderDevice,
displayName:
binding.alias?.trim() || binding.elderDevice.displayName?.trim() || null,
},
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
familyUnreadCount,
elderUnreadCount,
};
}
export async function getCaregiverActivity(caregiverId: string) {
const deviceBindings = await prisma.deviceBinding.findMany({
where: { caregiverId },
select: {
elderDeviceId: true,
},
});
const elderDeviceIds = deviceBindings.map((binding) => binding.elderDeviceId);
if (elderDeviceIds.length === 0) {
return {
usageEvents: [],
conversationTurns: [],
toolCalls: [],
};
}
const [usageEvents, conversationTurns, toolCalls] = await prisma.$transaction([
prisma.usageEvent.findMany({
where: {
elderDeviceId: { in: elderDeviceIds },
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
orderBy: { createdAt: "desc" },
take: 24,
}),
prisma.conversationTurn.findMany({
where: {
elderDeviceId: { in: elderDeviceIds },
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
orderBy: { createdAt: "desc" },
take: 24,
}),
prisma.toolCallLog.findMany({
where: {
elderDeviceId: { in: elderDeviceIds },
},
include: {
elderDevice: {
select: deviceSummarySelect,
},
},
orderBy: { createdAt: "desc" },
take: 24,
}),
]);
const deviceNameMap = await loadCaregiverDeviceNameMap(caregiverId, elderDeviceIds);
applyCaregiverDeviceNames(usageEvents, deviceNameMap);
applyCaregiverDeviceNames(conversationTurns, deviceNameMap);
applyCaregiverDeviceNames(toolCalls, deviceNameMap);
return {
usageEvents,
conversationTurns,
toolCalls,
};
}
export async function getCaregiverSettingsOverview(caregiverId: string) {
const [deviceCount, pushSubscriptionCount] = await prisma.$transaction([
prisma.deviceBinding.count({
where: { caregiverId },
}),
prisma.pushSubscription.count({
where: { caregiverId },
}),
]);
return {
deviceCount,
pushSubscriptionCount,
};
}
+29
View File
@@ -0,0 +1,29 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { prisma } from "@/lib/prisma";
export function hashDeviceToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export function createDeviceToken() {
return randomBytes(32).toString("base64url");
}
export function readDeviceToken(request: Request) {
const authorization = request.headers.get("authorization") || "";
return authorization.startsWith("Bearer ")
? authorization.slice(7).trim()
: request.headers.get("x-device-token")?.trim() || "";
}
export async function requireDeviceAuth(request: Request, deviceUuid: string) {
const token = readDeviceToken(request);
if (!token) throw new Error("设备认证信息缺失,请重新注册设备。");
const device = await prisma.elderDevice.findUnique({ where: { deviceUuid }, select: { id: true, deviceUuid: true, deviceTokenHash: true } });
if (!device?.deviceTokenHash) throw new Error("设备尚未完成安全注册。");
const actual = Buffer.from(hashDeviceToken(token));
const expected = Buffer.from(device.deviceTokenHash);
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) throw new Error("设备认证失败,请重新注册设备。");
return device;
}
+372
View File
@@ -0,0 +1,372 @@
import {
AlertLevel,
AlertStatus,
CareTaskStatus,
CheckInStatus,
ContentVisibility,
FamilyRole,
MedicationDoseStatus,
MedicationSource,
MembershipStatus,
MemoPriority,
InvitationStatus,
} from "@prisma/client";
import { createHash, randomBytes } from "node:crypto";
import { prisma } from "@/lib/prisma";
const ACTIVE_MEMBER = { status: MembershipStatus.ACTIVE } as const;
function requiredText(value: unknown, label: string, max = 500) {
if (typeof value !== "string" || !value.trim()) throw new Error(`${label}不能为空。`);
return value.trim().slice(0, max);
}
function optionalText(value: unknown, max = 500) {
return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : null;
}
function parseDate(value: unknown) {
if (typeof value !== "string" || !value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
export async function ensureFamilyCircle(caregiverId: string, deviceUuid?: string) {
const binding = await prisma.deviceBinding.findFirst({
where: {
caregiverId,
elderDevice: deviceUuid ? { deviceUuid } : undefined,
},
orderBy: { createdAt: "asc" },
include: {
elderDevice: {
include: { elderProfile: { include: { familyCircle: true } } },
},
},
});
if (!binding) throw new Error("请先绑定一台长辈设备。 ");
let circle = binding.elderDevice.elderProfile?.familyCircle;
if (!circle) {
circle = await prisma.$transaction(async (tx) => {
const profile = await tx.elderProfile.upsert({
where: { elderDeviceId: binding.elderDeviceId },
update: {},
create: {
elderDeviceId: binding.elderDeviceId,
preferredName: binding.alias || binding.elderDevice.displayName || "长辈",
consentSetting: { create: {} },
},
});
return tx.familyCircle.upsert({
where: { elderProfileId: profile.id },
update: {},
create: {
elderProfileId: profile.id,
createdById: caregiverId,
name: `${profile.preferredName}的守护圈`,
},
});
});
}
const allBindings = await prisma.deviceBinding.findMany({
where: { elderDeviceId: binding.elderDeviceId },
orderBy: { createdAt: "asc" },
select: { caregiverId: true, createdAt: true },
});
await prisma.$transaction(
allBindings.map((item, index) =>
prisma.familyMembership.upsert({
where: {
familyCircleId_caregiverId: {
familyCircleId: circle!.id,
caregiverId: item.caregiverId,
},
},
update: { status: MembershipStatus.ACTIVE },
create: {
familyCircleId: circle!.id,
caregiverId: item.caregiverId,
role: index === 0 ? FamilyRole.ADMIN : FamilyRole.CAREGIVER,
dutyOrder: index + 1,
},
}),
),
);
return circle;
}
export async function requireCircleMember(caregiverId: string, circleId: string) {
const member = await prisma.familyMembership.findFirst({
where: { caregiverId, familyCircleId: circleId, ...ACTIVE_MEMBER },
});
if (!member) throw new Error("您不是这个家庭守护圈的成员。");
return member;
}
export async function getFamilyWorkspace(caregiverId: string, deviceUuid?: string) {
const circle = await ensureFamilyCircle(caregiverId, deviceUuid);
await requireCircleMember(caregiverId, circle.id);
return prisma.familyCircle.findUniqueOrThrow({
where: { id: circle.id },
include: {
elderProfile: { include: { elderDevice: true, consentSetting: true } },
memberships: {
where: ACTIVE_MEMBER,
orderBy: [{ dutyOrder: "asc" }, { createdAt: "asc" }],
include: { caregiver: { select: { id: true, nickname: true, username: true } } },
},
posts: { orderBy: [{ pinned: "desc" }, { createdAt: "desc" }], take: 30, include: { author: true } },
memos: { orderBy: [{ priority: "desc" }, { updatedAt: "desc" }], take: 30, include: { author: true } },
tasks: { orderBy: [{ status: "asc" }, { dueAt: "asc" }, { createdAt: "desc" }], take: 40, include: { createdBy: true, assignedTo: true } },
medicationPlans: { orderBy: { createdAt: "desc" }, include: { doses: { orderBy: { scheduledFor: "desc" }, take: 14 } } },
checkIns: { orderBy: { createdAt: "desc" }, take: 20, include: { requestedBy: true } },
alerts: { orderBy: { createdAt: "desc" }, take: 30, include: { claimedBy: true, safetySignal: true } },
},
});
}
function invitationHash(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export async function createFamilyInvitation(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
const member = await requireCircleMember(caregiverId, circle.id);
if (member.role !== FamilyRole.ADMIN) throw new Error("只有管理员可以邀请家庭成员。");
const token = randomBytes(24).toString("base64url");
const role = input.role === FamilyRole.CAREGIVER ? FamilyRole.CAREGIVER : FamilyRole.CARING;
await prisma.familyInvitation.create({
data: {
familyCircleId: circle.id,
createdById: caregiverId,
tokenHash: invitationHash(token), role,
relationLabel: optionalText(input.relationLabel, 30),
expiresAt: new Date(Date.now() + 24 * 60 * 60_000),
},
});
await audit(circle.id, caregiverId, "INVITE", "FamilyMembership", undefined, { role });
const baseUrl = (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000").replace(/\/$/, "");
return { inviteUrl: `${baseUrl}/family/join?token=${encodeURIComponent(token)}`, expiresAt: new Date(Date.now() + 24 * 60 * 60_000) };
}
export async function acceptFamilyInvitation(caregiverId: string, token: string) {
const invitation = await prisma.familyInvitation.findUnique({
where: { tokenHash: invitationHash(requiredText(token, "邀请口令", 200)) },
include: { familyCircle: { include: { elderProfile: true } } },
});
if (!invitation || invitation.status !== InvitationStatus.PENDING) throw new Error("邀请链接无效或已被使用。");
if (invitation.expiresAt <= new Date()) {
await prisma.familyInvitation.update({ where: { id: invitation.id }, data: { status: InvitationStatus.EXPIRED } });
throw new Error("邀请链接已过期,请让管理员重新邀请。");
}
await prisma.$transaction([
prisma.familyMembership.upsert({
where: { familyCircleId_caregiverId: { familyCircleId: invitation.familyCircleId, caregiverId } },
update: { status: MembershipStatus.ACTIVE, role: invitation.role, relationLabel: invitation.relationLabel },
create: { familyCircleId: invitation.familyCircleId, caregiverId, role: invitation.role, relationLabel: invitation.relationLabel },
}),
prisma.deviceBinding.upsert({
where: { caregiverId_elderDeviceId: { caregiverId, elderDeviceId: invitation.familyCircle.elderProfile.elderDeviceId } },
update: {}, create: { caregiverId, elderDeviceId: invitation.familyCircle.elderProfile.elderDeviceId },
}),
prisma.familyInvitation.update({ where: { id: invitation.id }, data: { status: InvitationStatus.ACCEPTED, acceptedAt: new Date() } }),
]);
await audit(invitation.familyCircleId, caregiverId, "JOIN", "FamilyMembership");
return invitation.familyCircle;
}
export async function createFamilyPost(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
await requireCircleMember(caregiverId, circle.id);
const visibility = Object.values(ContentVisibility).includes(input.visibility as ContentVisibility)
? (input.visibility as ContentVisibility) : ContentVisibility.FAMILY_AND_ELDER;
const post = await prisma.familyPost.create({
data: { familyCircleId: circle.id, authorId: caregiverId, content: requiredText(input.content, "动态内容", 1000), visibility },
});
await audit(circle.id, caregiverId, "CREATE", "FamilyPost", post.id);
return post;
}
export async function createFamilyMemo(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
const member = await requireCircleMember(caregiverId, circle.id);
if (member.role === FamilyRole.CARING) throw new Error("关怀成员不能修改家庭备忘录。");
const priority = Object.values(MemoPriority).includes(input.priority as MemoPriority)
? (input.priority as MemoPriority) : MemoPriority.NORMAL;
const memo = await prisma.familyMemo.create({
data: {
familyCircleId: circle.id,
authorId: caregiverId,
title: requiredText(input.title, "备忘标题", 80),
content: requiredText(input.content, "备忘内容", 1000),
priority,
visibility: input.visibility === ContentVisibility.FAMILY_ONLY ? ContentVisibility.FAMILY_ONLY : ContentVisibility.FAMILY_AND_ELDER,
},
});
await audit(circle.id, caregiverId, "CREATE", "FamilyMemo", memo.id);
return memo;
}
export async function createCareTask(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
const member = await requireCircleMember(caregiverId, circle.id);
if (member.role === FamilyRole.CARING) throw new Error("关怀成员不能创建照护任务。");
const task = await prisma.careTask.create({
data: {
familyCircleId: circle.id,
createdById: caregiverId,
title: requiredText(input.title, "任务标题", 100),
description: optionalText(input.description, 1000),
dueAt: parseDate(input.dueAt),
},
});
await audit(circle.id, caregiverId, "CREATE", "CareTask", task.id);
return task;
}
export async function updateCareTask(caregiverId: string, taskId: string, action: string, note?: unknown) {
const task = await prisma.careTask.findUniqueOrThrow({ where: { id: taskId } });
await requireCircleMember(caregiverId, task.familyCircleId);
const data = action === "claim"
? { status: CareTaskStatus.CLAIMED, assignedToId: caregiverId }
: action === "complete"
? { status: CareTaskStatus.COMPLETED, assignedToId: task.assignedToId || caregiverId, completedAt: new Date(), completionNote: optionalText(note, 500) }
: action === "reopen"
? { status: CareTaskStatus.OPEN, assignedToId: null, completedAt: null, completionNote: null }
: null;
if (!data) throw new Error("不支持的任务操作。");
const updated = await prisma.careTask.update({ where: { id: taskId }, data });
await audit(task.familyCircleId, caregiverId, action.toUpperCase(), "CareTask", taskId);
return updated;
}
function parseTimes(value: unknown) {
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
const times = values.map(String).map((v) => v.trim()).filter((v) => /^([01]\d|2[0-3]):[0-5]\d$/.test(v));
if (!times.length) throw new Error("请至少填写一个 HH:mm 格式的服药时间。");
return [...new Set(times)].sort();
}
function scheduledDate(dayOffset: number, time: string) {
const now = new Date();
const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset);
const [hour, minute] = time.split(":").map(Number);
date.setHours(hour, minute, 0, 0);
return date;
}
export async function createMedicationPlan(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
const member = await requireCircleMember(caregiverId, circle.id);
if (member.role === FamilyRole.CARING) throw new Error("关怀成员不能管理用药计划。");
const times = parseTimes(input.times);
const source = Object.values(MedicationSource).includes(input.source as MedicationSource)
? (input.source as MedicationSource) : MedicationSource.FAMILY_ENTRY;
const startDate = parseDate(input.startDate) || new Date();
const plan = await prisma.medicationPlan.create({
data: {
familyCircleId: circle.id,
createdById: caregiverId,
name: requiredText(input.name, "药品名称", 100),
dosage: requiredText(input.dosage, "单次用量", 100),
instructions: optionalText(input.instructions, 300),
timesJson: JSON.stringify(times), source, startDate,
endDate: parseDate(input.endDate),
stockCount: typeof input.stockCount === "number" ? Math.max(0, Math.floor(input.stockCount)) : null,
doses: { create: times.flatMap((time) => Array.from({ length: 7 }, (_, day) => ({ scheduledFor: scheduledDate(day, time) }))) },
},
include: { doses: true },
});
await audit(circle.id, caregiverId, "CREATE", "MedicationPlan", plan.id, { source });
return plan;
}
export async function createCheckIn(caregiverId: string, input: Record<string, unknown>) {
const circle = await ensureFamilyCircle(caregiverId, optionalText(input.deviceUuid, 64) || undefined);
await requireCircleMember(caregiverId, circle.id);
const checkIn = await prisma.checkInRequest.create({
data: { familyCircleId: circle.id, requestedById: caregiverId, prompt: optionalText(input.prompt, 240), dueAt: parseDate(input.dueAt) },
});
await audit(circle.id, caregiverId, "CREATE", "CheckInRequest", checkIn.id);
return checkIn;
}
export async function updateAlert(caregiverId: string, alertId: string, action: string, note?: unknown) {
const alert = await prisma.safetyAlert.findUniqueOrThrow({ where: { id: alertId } });
await requireCircleMember(caregiverId, alert.familyCircleId);
const data = action === "claim"
? { status: AlertStatus.ACKNOWLEDGED, claimedById: caregiverId, claimedAt: new Date() }
: action === "resolve"
? { status: AlertStatus.RESOLVED, claimedById: alert.claimedById || caregiverId, claimedAt: alert.claimedAt || new Date(), resolvedAt: new Date(), resolutionNote: requiredText(note, "处理结果", 500) }
: action === "dismiss"
? { status: AlertStatus.DISMISSED, claimedById: alert.claimedById || caregiverId, resolvedAt: new Date(), resolutionNote: optionalText(note, 500) || "已确认是误报" }
: null;
if (!data) throw new Error("不支持的告警操作。");
const updated = await prisma.safetyAlert.update({ where: { id: alertId }, data });
await audit(alert.familyCircleId, caregiverId, action.toUpperCase(), "SafetyAlert", alertId, { note });
return updated;
}
export async function getDeviceAgenda(deviceUuid: string) {
const profile = await prisma.elderProfile.findFirst({
where: { elderDevice: { deviceUuid } },
include: { familyCircle: true },
});
if (!profile?.familyCircle) return { circle: null, posts: [], memos: [], doses: [], checkIns: [] };
const circleId = profile.familyCircle.id;
const now = new Date();
const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dayEnd = new Date(dayStart); dayEnd.setDate(dayEnd.getDate() + 1);
const [posts, memos, doses, checkIns] = await Promise.all([
prisma.familyPost.findMany({ where: { familyCircleId: circleId, visibility: { in: [ContentVisibility.ELDER_ONLY, ContentVisibility.FAMILY_AND_ELDER] }, deliveredAt: null }, include: { author: true }, orderBy: { createdAt: "asc" }, take: 8 }),
prisma.familyMemo.findMany({ where: { familyCircleId: circleId, visibility: { in: [ContentVisibility.ELDER_ONLY, ContentVisibility.FAMILY_AND_ELDER] } }, orderBy: [{ priority: "desc" }, { updatedAt: "desc" }], take: 12 }),
prisma.medicationDose.findMany({ where: { medicationPlan: { familyCircleId: circleId, active: true }, scheduledFor: { gte: dayStart, lt: dayEnd }, status: { in: [MedicationDoseStatus.SCHEDULED, MedicationDoseStatus.REMINDING, MedicationDoseStatus.SNOOZED] } }, include: { medicationPlan: true }, orderBy: { scheduledFor: "asc" } }),
prisma.checkInRequest.findMany({ where: { familyCircleId: circleId, status: CheckInStatus.PENDING }, orderBy: { createdAt: "asc" }, take: 5 }),
]);
if (posts.length) {
await prisma.familyPost.updateMany({ where: { id: { in: posts.map((post) => post.id) } }, data: { deliveredAt: new Date() } });
}
return { circle: profile.familyCircle, posts, memos, doses, checkIns };
}
export async function updateMedicationDose(deviceUuid: string, doseId: string, status: MedicationDoseStatus, note?: unknown, snoozeMinutes = 10) {
const dose = await prisma.medicationDose.findFirst({ where: { id: doseId, medicationPlan: { familyCircle: { elderProfile: { elderDevice: { deviceUuid } } } } }, include: { medicationPlan: true } });
if (!dose) throw new Error("没有找到这次服药提醒。");
if (dose.status === MedicationDoseStatus.TAKEN || dose.status === MedicationDoseStatus.SKIPPED) throw new Error("这次服药已经确认,不能重复提交。");
return prisma.medicationDose.update({
where: { id: doseId },
data: {
status,
respondedAt: status === MedicationDoseStatus.SNOOZED ? null : new Date(),
snoozedUntil: status === MedicationDoseStatus.SNOOZED ? new Date(Date.now() + Math.min(Math.max(snoozeMinutes, 1), 60) * 60_000) : null,
note: optionalText(note, 500),
},
});
}
export async function respondCheckIn(deviceUuid: string, checkInId: string, status: CheckInStatus, responseText?: unknown) {
const checkIn = await prisma.checkInRequest.findFirst({ where: { id: checkInId, familyCircle: { elderProfile: { elderDevice: { deviceUuid } } }, status: CheckInStatus.PENDING } });
if (!checkIn) throw new Error("没有找到待回应的报平安请求。");
return prisma.checkInRequest.update({ where: { id: checkInId }, data: { status, responseText: optionalText(responseText, 500), respondedAt: new Date() } });
}
export async function createSafetyAlertForDevice(deviceUuid: string, input: Record<string, unknown>) {
const profile = await prisma.elderProfile.findFirst({ where: { elderDevice: { deviceUuid } }, include: { familyCircle: true } });
if (!profile?.familyCircle) throw new Error("这台设备还没有加入家庭守护圈。");
const category = requiredText(input.category, "风险类别", 80);
const summary = requiredText(input.summary, "风险说明", 500);
const since = new Date(Date.now() - 30 * 60_000);
const duplicate = await prisma.safetyAlert.findFirst({ where: { familyCircleId: profile.familyCircle.id, status: { in: [AlertStatus.OPEN, AlertStatus.ACKNOWLEDGED] }, safetySignal: { category, createdAt: { gte: since } } }, orderBy: { createdAt: "desc" } });
if (duplicate) return { alert: duplicate, deduplicated: true };
const level = Object.values(AlertLevel).includes(input.level as AlertLevel) ? (input.level as AlertLevel) : AlertLevel.ORANGE;
const signal = await prisma.safetySignal.create({ data: { familyCircleId: profile.familyCircle.id, category, summary, contextText: optionalText(input.contextText, 1000), confidence: typeof input.confidence === "number" ? Math.min(Math.max(input.confidence, 0), 1) : null } });
const alert = await prisma.safetyAlert.create({ data: { familyCircleId: profile.familyCircle.id, safetySignalId: signal.id, level, title: requiredText(input.title, "提醒标题", 100), summary } });
return { alert, deduplicated: false };
}
async function audit(circleId: string, actorId: string | null, action: string, entityType: string, entityId?: string, detail?: unknown) {
await prisma.auditLog.create({ data: { familyCircleId: circleId, actorId, action, entityType, entityId, detailJson: detail === undefined ? null : JSON.stringify(detail) } });
}
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
import { getFamilyWorkspace } from "@/lib/family-guard";
export async function requireFamilyWorkspace(caregiverId: string) {
try {
return await getFamilyWorkspace(caregiverId);
} catch (error) {
if (error instanceof Error && error.message.includes("绑定一台长辈设备")) redirect("/devices");
throw error;
}
}
+229 -3
View File
@@ -7,6 +7,7 @@ import {
UsageEventType, UsageEventType,
} from "@prisma/client"; } from "@prisma/client";
import { sendIncomingMessagePush } from "@/lib/push";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
type RegisterDeviceInput = { type RegisterDeviceInput = {
@@ -45,6 +46,7 @@ type ToolCallInput = {
const PUBLIC_BASE_URL = ( const PUBLIC_BASE_URL = (
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net" process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
).replace(/\/+$/, ""); ).replace(/\/+$/, "");
const DEVICE_ALIAS_MAX_LENGTH = 24;
const DEVICE_UUID_PATTERN = const DEVICE_UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -89,6 +91,24 @@ export function extractDeviceUuid(rawValue: string) {
return null; return null;
} }
function normalizeDeviceAlias(rawAlias?: string | null) {
const alias = rawAlias?.trim() || null;
if (!alias) {
return null;
}
if (alias.length > DEVICE_ALIAS_MAX_LENGTH) {
throw new Error(`设备别名请控制在 ${DEVICE_ALIAS_MAX_LENGTH} 个字以内。`);
}
return alias;
}
function resolveBindingDisplayName(alias?: string | null, displayName?: string | null) {
return alias?.trim() || displayName?.trim() || null;
}
export async function ensureDeviceRegistration(input: RegisterDeviceInput) { export async function ensureDeviceRegistration(input: RegisterDeviceInput) {
const deviceUuid = normalizeDeviceUuid(input.deviceUuid); const deviceUuid = normalizeDeviceUuid(input.deviceUuid);
@@ -120,6 +140,8 @@ export async function bindDeviceToCaregiver(
const device = await ensureDeviceRegistration({ deviceUuid }); const device = await ensureDeviceRegistration({ deviceUuid });
await migrateLegacyCaregiverDataForDevice(caregiverId, device.id);
await prisma.deviceBinding.upsert({ await prisma.deviceBinding.upsert({
where: { where: {
caregiverId_elderDeviceId: { caregiverId_elderDeviceId: {
@@ -137,6 +159,104 @@ export async function bindDeviceToCaregiver(
return device; return device;
} }
async function migrateLegacyCaregiverDataForDevice(
caregiverId: string,
elderDeviceId: string,
) {
const legacyCaregivers = await prisma.caregiverAccount.findMany({
where: {
id: { not: caregiverId },
username: null,
bindings: {
some: {
elderDeviceId,
},
},
},
select: {
id: true,
},
take: 2,
});
if (legacyCaregivers.length !== 1) {
return;
}
const legacyCaregiverId = legacyCaregivers[0]?.id;
if (!legacyCaregiverId) {
return;
}
await prisma.$transaction(async (tx) => {
const legacyBindings = await tx.deviceBinding.findMany({
where: {
caregiverId: legacyCaregiverId,
},
select: {
elderDeviceId: true,
alias: true,
},
});
await Promise.all(
legacyBindings.map((binding) =>
tx.deviceBinding.upsert({
where: {
caregiverId_elderDeviceId: {
caregiverId,
elderDeviceId: binding.elderDeviceId,
},
},
update: binding.alias ? { alias: binding.alias } : {},
create: {
caregiverId,
elderDeviceId: binding.elderDeviceId,
alias: binding.alias,
},
}),
),
);
await tx.familyMessage.updateMany({
where: {
caregiverId: legacyCaregiverId,
},
data: {
caregiverId,
},
});
await tx.pushSubscription.updateMany({
where: {
caregiverId: legacyCaregiverId,
},
data: {
caregiverId,
},
});
await tx.deviceBinding.deleteMany({
where: {
caregiverId: legacyCaregiverId,
},
});
await tx.caregiverSession.deleteMany({
where: {
caregiverId: legacyCaregiverId,
},
});
await tx.caregiverAccount.delete({
where: {
id: legacyCaregiverId,
},
});
});
}
function parseImportance(rawImportance?: string | null) { function parseImportance(rawImportance?: string | null) {
const normalizedImportance = rawImportance?.trim().toUpperCase(); const normalizedImportance = rawImportance?.trim().toUpperCase();
@@ -294,6 +414,76 @@ export async function createFamilyMessageFromCaregiver(input: {
}); });
} }
async function findCaregiverDeviceBinding(
caregiverId: string,
rawDeviceValue: string,
) {
const deviceUuid = extractDeviceUuid(rawDeviceValue);
if (!deviceUuid) {
throw new Error("设备码格式不正确,请重新扫码或粘贴。");
}
const binding = await prisma.deviceBinding.findFirst({
where: {
caregiverId,
elderDevice: {
deviceUuid,
},
},
include: {
elderDevice: true,
},
});
if (!binding) {
throw new Error("当前家属账号还没有绑定这台设备。");
}
return binding;
}
export async function updateCaregiverDeviceAlias(input: {
caregiverId: string;
rawDeviceValue: string;
alias?: string | null;
}) {
const binding = await findCaregiverDeviceBinding(
input.caregiverId,
input.rawDeviceValue,
);
const alias = normalizeDeviceAlias(input.alias);
return prisma.deviceBinding.update({
where: {
id: binding.id,
},
data: {
alias,
},
include: {
elderDevice: true,
},
});
}
export async function unbindDeviceFromCaregiver(
caregiverId: string,
rawDeviceValue: string,
) {
const binding = await findCaregiverDeviceBinding(caregiverId, rawDeviceValue);
await prisma.deviceBinding.delete({
where: {
id: binding.id,
},
});
return {
deviceUuid: binding.elderDevice.deviceUuid,
};
}
export async function recordUsageEvent(input: UsageEventInput) { export async function recordUsageEvent(input: UsageEventInput) {
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid }); const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
@@ -338,7 +528,7 @@ export async function recordConversationTurns(input: ConversationTurnInput) {
export async function recordToolCall(input: ToolCallInput) { export async function recordToolCall(input: ToolCallInput) {
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid }); const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
return prisma.$transaction(async (tx) => { const result = await prisma.$transaction(async (tx) => {
const log = await tx.toolCallLog.create({ const log = await tx.toolCallLog.create({
data: { data: {
elderDeviceId: device.id, elderDeviceId: device.id,
@@ -367,13 +557,14 @@ export async function recordToolCall(input: ToolCallInput) {
typeof input.arguments.message === "string" typeof input.arguments.message === "string"
? input.arguments.message.trim() ? input.arguments.message.trim()
: ""; : "";
let createdMessagePublicId: number | null = null;
if ( if (
input.status === ToolCallStatus.SUCCESS && input.status === ToolCallStatus.SUCCESS &&
input.toolName === "leave_message_for_family" && input.toolName === "leave_message_for_family" &&
messageText messageText
) { ) {
await tx.familyMessage.create({ const createdMessage = await tx.familyMessage.create({
data: { data: {
elderDeviceId: device.id, elderDeviceId: device.id,
direction: MessageDirection.ELDER_TO_FAMILY, direction: MessageDirection.ELDER_TO_FAMILY,
@@ -392,10 +583,38 @@ export async function recordToolCall(input: ToolCallInput) {
source: "MODEL_TOOL", source: "MODEL_TOOL",
}, },
}); });
createdMessagePublicId = createdMessage.publicId;
const circle = await tx.familyCircle.findFirst({
where: { elderProfile: { elderDeviceId: device.id } },
select: { id: true },
});
if (circle) {
await tx.familyPost.create({
data: {
familyCircleId: circle.id,
content: messageText,
elderAuthored: true,
visibility: "FAMILY_AND_ELDER",
deliveredAt: new Date(),
},
});
}
} }
return log; return {
log,
createdMessagePublicId,
};
}); });
if (result.createdMessagePublicId) {
void sendIncomingMessagePush(result.createdMessagePublicId).catch(() => {
return undefined;
});
}
return result.log;
} }
export async function getCaregiverDashboard(caregiverId: string) { export async function getCaregiverDashboard(caregiverId: string) {
@@ -478,6 +697,13 @@ export async function getCaregiverDashboard(caregiverId: string) {
caregiver, caregiver,
devices: caregiver.bindings.map((binding) => ({ devices: caregiver.bindings.map((binding) => ({
...binding, ...binding,
elderDevice: {
...binding.elderDevice,
displayName: resolveBindingDisplayName(
binding.alias,
binding.elderDevice.displayName,
),
},
familyUnreadCount: familyUnreadMap.get(binding.elderDeviceId) || 0, familyUnreadCount: familyUnreadMap.get(binding.elderDeviceId) || 0,
elderUnreadCount: elderUnreadMap.get(binding.elderDeviceId) || 0, elderUnreadCount: elderUnreadMap.get(binding.elderDeviceId) || 0,
bindUrl: createBindUrl(binding.elderDevice.deviceUuid), bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
+16
View File
@@ -0,0 +1,16 @@
import { redirect } from "next/navigation";
import {
buildCaregiverLoginPath,
getCaregiverSession,
} from "@/lib/session";
export async function requireCaregiverSession(redirectTo: string) {
const caregiver = await getCaregiverSession();
if (!caregiver) {
redirect(buildCaregiverLoginPath(redirectTo));
}
return caregiver;
}
+117
View File
@@ -0,0 +1,117 @@
import {
ConversationRole,
MessageDirection,
MessageImportance,
MessageStatus,
UsageEventType,
} from "@prisma/client";
export function formatDateTime(value: Date | string | null | undefined) {
if (!value) {
return "暂时还没有记录";
}
const date = value instanceof Date ? value : new Date(value);
return new Intl.DateTimeFormat("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
export function getImportanceLabel(level: MessageImportance | string) {
switch (level) {
case MessageImportance.LOW:
return "温和提醒";
case MessageImportance.HIGH:
return "希望尽快看到";
case MessageImportance.URGENT:
return "需要尽快联系";
default:
return "日常问候";
}
}
export function getUsageLabel(eventType: UsageEventType | string) {
switch (eventType) {
case UsageEventType.APP_OPEN:
return "打开了老人端应用";
case UsageEventType.SETTINGS_OPENED:
return "查看了设置页面";
case UsageEventType.AI_SESSION_STARTED:
return "开始了一次陪伴对话";
case UsageEventType.AI_SESSION_ENDED:
return "结束了一次陪伴对话";
case UsageEventType.CAMERA_ENABLED:
return "打开了镜头模式";
case UsageEventType.CAMERA_DISABLED:
return "关闭了镜头模式";
case UsageEventType.TOOL_CALLED:
return "使用了一项智能服务";
default:
return eventType;
}
}
export function getConversationRoleLabel(role: ConversationRole | string) {
switch (role) {
case ConversationRole.USER:
return "老人说";
case ConversationRole.TOOL:
return "智能助理";
default:
return "数字人回复";
}
}
export function getMessageDirectionLabel(direction: MessageDirection | string) {
return direction === MessageDirection.ELDER_TO_FAMILY
? "老人给家里"
: "家属给老人";
}
export function getMessageStatusLabel(input: {
direction: MessageDirection | string;
status: MessageStatus | string;
readAt?: Date | string | null;
deliveredAt?: Date | string | null;
}) {
if (input.direction === MessageDirection.ELDER_TO_FAMILY) {
return input.readAt ? "家属已打开" : "等待家属查看";
}
if (input.readAt) {
return "老人已看过";
}
if (input.deliveredAt) {
return "已送到老人设备";
}
if (input.status === MessageStatus.PENDING) {
return "等待老人设备拉取";
}
return "等待老人查看";
}
export function truncateText(content: string, maxLength = 88) {
if (content.length <= maxLength) {
return content;
}
return `${content.slice(0, maxLength).trimEnd()}...`;
}
export function getMessageHref(publicId: number) {
return `/messages/${publicId}`;
}
export function getDeviceName(
primaryName?: string | null,
fallbackName?: string | null,
) {
return primaryName?.trim() || fallbackName?.trim() || "长辈的设备";
}
+458
View File
@@ -0,0 +1,458 @@
import { MessageDirection } from "@prisma/client";
import webpush from "web-push";
import { getDeviceName, truncateText } from "@/lib/panel-format";
import { prisma } from "@/lib/prisma";
type SerializablePushSubscription = {
endpoint: string;
expirationTime?: number | null;
keys?: {
p256dh?: string;
auth?: string;
};
};
type StoredPushSubscription = {
endpoint: string;
expirationTime: Date | null;
p256dh: string;
auth: string;
};
type PushDeliverySummary = {
targetedCount: number;
sentCount: number;
removedCount: number;
failedCount: number;
};
type PushFailureDetail = {
endpointHost: string;
statusCode: number | null;
message: string;
body: string | null;
reason: string | null;
};
type PushDeliveryResult = PushDeliverySummary & {
failureDetails?: PushFailureDetail[];
};
type PushAttemptResult = {
outcome: "sent" | "removed" | "failed";
detail?: PushFailureDetail;
};
const APP_BASE_URL = (
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
).replace(/\/+$/, "");
let vapidReady = false;
function hasPushConfiguration() {
return Boolean(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY &&
process.env.VAPID_PRIVATE_KEY &&
process.env.VAPID_SUBJECT,
);
}
function ensureVapidDetails() {
if (!hasPushConfiguration()) {
throw new Error("VAPID 配置尚未完成,暂时不能发送推送通知。");
}
if (vapidReady) {
return;
}
webpush.setVapidDetails(
process.env.VAPID_SUBJECT!,
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!,
);
vapidReady = true;
}
function normalizeSubscription(input: SerializablePushSubscription) {
const endpoint = input.endpoint?.trim();
const p256dh = input.keys?.p256dh?.trim();
const auth = input.keys?.auth?.trim();
if (!endpoint || !p256dh || !auth) {
throw new Error("推送订阅信息不完整。");
}
return {
endpoint,
p256dh,
auth,
expirationTime:
typeof input.expirationTime === "number"
? new Date(input.expirationTime)
: null,
};
}
function absoluteUrl(path: string) {
if (/^https?:\/\//i.test(path)) {
return path;
}
return `${APP_BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
}
export function getPushPublicKey() {
return process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
}
export function isPushAvailable() {
return hasPushConfiguration();
}
export async function savePushSubscription(input: {
caregiverId: string;
subscription: SerializablePushSubscription;
userAgent?: string | null;
}) {
const subscription = normalizeSubscription(input.subscription);
return prisma.pushSubscription.upsert({
where: { endpoint: subscription.endpoint },
update: {
caregiverId: input.caregiverId,
p256dh: subscription.p256dh,
auth: subscription.auth,
expirationTime: subscription.expirationTime,
userAgent: input.userAgent?.trim() || null,
},
create: {
caregiverId: input.caregiverId,
endpoint: subscription.endpoint,
p256dh: subscription.p256dh,
auth: subscription.auth,
expirationTime: subscription.expirationTime,
userAgent: input.userAgent?.trim() || null,
},
});
}
export async function removePushSubscription(input: {
caregiverId: string;
endpoint: string;
}) {
await prisma.pushSubscription.deleteMany({
where: {
caregiverId: input.caregiverId,
endpoint: input.endpoint.trim(),
},
});
}
function getEndpointHost(endpoint: string) {
try {
return new URL(endpoint).host;
} catch {
return "unknown";
}
}
function shouldOmitTopic(endpoint: string) {
return getEndpointHost(endpoint) === "web.push.apple.com";
}
function getPushFailureDetail(subscription: StoredPushSubscription, error: unknown) {
const statusCode =
error && typeof error === "object" && "statusCode" in error
? Number((error as { statusCode?: number }).statusCode)
: null;
const message =
error && typeof error === "object" && "message" in error
? String((error as { message?: string }).message || "推送发送失败")
: "推送发送失败";
const body =
error && typeof error === "object" && "body" in error
? String((error as { body?: string }).body || "") || null
: null;
let reason: string | null = null;
if (body) {
try {
const parsedBody = JSON.parse(body) as { reason?: unknown };
reason = typeof parsedBody.reason === "string" ? parsedBody.reason : null;
} catch {
reason = null;
}
}
return {
endpointHost: getEndpointHost(subscription.endpoint),
statusCode,
message,
body,
reason,
} satisfies PushFailureDetail;
}
async function sendPushNotification(input: {
subscription: StoredPushSubscription;
payload: string;
topic?: string;
collectFailureDetails?: boolean;
}) {
const requestOptions: {
TTL: number;
urgency: "high";
topic?: string;
} = {
TTL: 60 * 30,
urgency: "high",
};
if (input.topic && !shouldOmitTopic(input.subscription.endpoint)) {
requestOptions.topic = input.topic;
}
try {
await webpush.sendNotification(
{
endpoint: input.subscription.endpoint,
expirationTime: input.subscription.expirationTime?.getTime() || null,
keys: {
p256dh: input.subscription.p256dh,
auth: input.subscription.auth,
},
},
input.payload,
requestOptions,
);
return {
outcome: "sent",
} satisfies PushAttemptResult;
} catch (error) {
const detail = getPushFailureDetail(input.subscription, error);
const statusCode = detail.statusCode || 0;
console.error("Push notification delivery failed", detail);
if (statusCode === 404 || statusCode === 410) {
await prisma.pushSubscription.deleteMany({
where: { endpoint: input.subscription.endpoint },
});
return {
outcome: "removed",
detail: input.collectFailureDetails ? detail : undefined,
} satisfies PushAttemptResult;
}
return {
outcome: "failed",
detail: input.collectFailureDetails ? detail : undefined,
} satisfies PushAttemptResult;
}
}
async function deliverPushToSubscriptions(input: {
subscriptions: StoredPushSubscription[];
payload: string;
topic?: string;
collectFailureDetails?: boolean;
}) {
if (input.subscriptions.length === 0) {
return {
targetedCount: 0,
sentCount: 0,
removedCount: 0,
failedCount: 0,
failureDetails: [],
} satisfies PushDeliveryResult;
}
ensureVapidDetails();
const results = await Promise.all(
input.subscriptions.map(async (subscription) => {
return sendPushNotification({
subscription,
payload: input.payload,
topic: input.topic,
collectFailureDetails: input.collectFailureDetails,
});
}),
);
const failureDetails = results
.map((result) => result.detail)
.filter((detail): detail is PushFailureDetail => Boolean(detail));
return {
targetedCount: input.subscriptions.length,
sentCount: results.filter((result) => result.outcome === "sent").length,
removedCount: results.filter((result) => result.outcome === "removed").length,
failedCount: results.filter((result) => result.outcome === "failed").length,
failureDetails,
} satisfies PushDeliveryResult;
}
export async function sendIncomingMessagePush(publicId: number) {
if (!hasPushConfiguration()) {
return;
}
const message = await prisma.familyMessage.findUnique({
where: { publicId },
select: {
publicId: true,
content: true,
direction: true,
elderDeviceId: true,
elderDevice: {
select: {
deviceUuid: true,
displayName: true,
},
},
},
});
if (!message || message.direction !== MessageDirection.ELDER_TO_FAMILY) {
return;
}
const subscriptions = await prisma.pushSubscription.findMany({
where: {
caregiver: {
bindings: {
some: {
elderDeviceId: message.elderDeviceId,
},
},
},
},
select: {
endpoint: true,
expirationTime: true,
p256dh: true,
auth: true,
caregiver: {
select: {
bindings: {
where: {
elderDeviceId: message.elderDeviceId,
},
select: {
alias: true,
},
take: 1,
},
},
},
},
});
if (subscriptions.length === 0) {
return;
}
ensureVapidDetails();
await Promise.all(
subscriptions.map(async (subscription) => {
const deviceName = getDeviceName(
subscription.caregiver.bindings[0]?.alias,
message.elderDevice.displayName,
);
const payload = JSON.stringify({
title: `${deviceName} 发来一条新留言`,
body: truncateText(message.content, 72),
url: absoluteUrl(`/messages/${message.publicId}`),
icon: absoluteUrl("/pwa/icon-192x192.png"),
badge: absoluteUrl("/pwa/badge-96x96.png"),
tag: `message-${message.publicId}`,
});
await sendPushNotification({
subscription: {
endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime,
p256dh: subscription.p256dh,
auth: subscription.auth,
},
payload,
topic: `msg-${message.publicId}`,
});
}),
);
}
export async function sendCaregiverTestPush(input: {
caregiverId: string;
caregiverLabel: string;
}) {
if (!hasPushConfiguration()) {
throw new Error("当前环境还没有配置推送服务。",
);
}
const subscriptions = await prisma.pushSubscription.findMany({
where: {
caregiverId: input.caregiverId,
},
select: {
endpoint: true,
expirationTime: true,
p256dh: true,
auth: true,
},
});
const payload = JSON.stringify({
title: "安智伴推送测试",
body: `${input.caregiverLabel} 账号下的通知链路工作正常。`,
url: absoluteUrl("/settings"),
icon: absoluteUrl("/pwa/icon-192x192.png"),
badge: absoluteUrl("/pwa/badge-96x96.png"),
tag: `push-test-${input.caregiverId}`,
});
return deliverPushToSubscriptions({
subscriptions,
payload,
topic: `test-${input.caregiverId.slice(0, 20)}`,
collectFailureDetails: true,
});
}
export async function sendGuardAlertPush(alertId: string) {
if (!hasPushConfiguration()) return;
const alert = await prisma.safetyAlert.findUnique({
where: { id: alertId },
include: {
familyCircle: {
include: {
elderProfile: { include: { elderDevice: true } },
memberships: {
where: { status: "ACTIVE", emergencyPush: true },
include: { caregiver: { include: { pushSubscriptions: true } } },
},
},
},
},
});
if (!alert) return;
const subscriptions = alert.familyCircle.memberships.flatMap((member) => member.caregiver.pushSubscriptions);
const payload = JSON.stringify({
title: `${alert.level === "RED" ? "紧急关注" : "守护提醒"} · ${alert.familyCircle.elderProfile.preferredName}`,
body: truncateText(alert.summary, 72),
url: absoluteUrl(`/guard#alert-${alert.id}`),
icon: absoluteUrl("/pwa/icon-192x192.png"),
badge: absoluteUrl("/pwa/badge-96x96.png"),
tag: `guard-alert-${alert.id}`,
});
await deliverPushToSubscriptions({ subscriptions, payload, topic: `guard-${alert.id}` });
}
+153 -24
View File
@@ -1,3 +1,4 @@
import { compare, hash } from "bcryptjs";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
@@ -6,6 +7,8 @@ import { prisma } from "@/lib/prisma";
export const CAREGIVER_SESSION_COOKIE = "dh-caregiver-session"; export const CAREGIVER_SESSION_COOKIE = "dh-caregiver-session";
const CAREGIVER_SESSION_MAX_AGE = 60 * 60 * 24 * 365; const CAREGIVER_SESSION_MAX_AGE = 60 * 60 * 24 * 365;
const CAREGIVER_USERNAME_PATTERN = /^[a-z0-9][a-z0-9_-]{2,23}$/;
const CAREGIVER_PASSWORD_MIN_LENGTH = 8;
function createSessionToken() { function createSessionToken() {
return randomUUID().replaceAll("-", ""); return randomUUID().replaceAll("-", "");
@@ -29,44 +32,170 @@ function normalizeRedirectPath(redirectTo?: string) {
return redirectTo; return redirectTo;
} }
async function upsertCaregiverByToken(sessionToken: string) { function normalizeCaregiverUsername(rawValue: string) {
return prisma.caregiverAccount.upsert({ return rawValue.trim().toLowerCase();
where: { sessionToken },
update: {},
create: { sessionToken },
});
} }
export function buildCaregiverSessionBootstrapPath(redirectTo: string) { function parseCaregiverUsername(rawValue: string) {
const username = normalizeCaregiverUsername(rawValue);
if (!CAREGIVER_USERNAME_PATTERN.test(username)) {
throw new Error("用户名请使用 3 到 24 位字母、数字、下划线或横线。");
}
return username;
}
function parseCaregiverPassword(rawValue: string) {
if (rawValue.length < CAREGIVER_PASSWORD_MIN_LENGTH) {
throw new Error(`密码至少需要 ${CAREGIVER_PASSWORD_MIN_LENGTH} 位。`);
}
if (rawValue.length > 72) {
throw new Error("密码请控制在 72 位以内。");
}
return rawValue;
}
async function getCurrentSessionToken() {
return (await cookies()).get(CAREGIVER_SESSION_COOKIE)?.value || null;
}
async function createCaregiverSession(input: {
caregiverId: string;
userAgent?: string | null;
}) {
const currentSessionToken = await getCurrentSessionToken();
if (currentSessionToken) {
await prisma.caregiverSession.deleteMany({
where: {
sessionToken: currentSessionToken,
},
});
}
const sessionToken = createSessionToken();
await prisma.caregiverSession.create({
data: {
caregiverId: input.caregiverId,
sessionToken,
userAgent: input.userAgent?.trim() || null,
lastSeenAt: new Date(),
},
});
(await cookies()).set(CAREGIVER_SESSION_COOKIE, sessionToken, getCookieOptions());
}
export function buildCaregiverLoginPath(redirectTo: string) {
const normalizedRedirect = normalizeRedirectPath(redirectTo); const normalizedRedirect = normalizeRedirectPath(redirectTo);
return `/api/family/session?redirectTo=${encodeURIComponent(normalizedRedirect)}`; return `/login?redirectTo=${encodeURIComponent(normalizedRedirect)}`;
}
export function getCaregiverDisplayName(input: {
username?: string | null;
nickname?: string | null;
}) {
return input.nickname?.trim() || input.username?.trim() || "临时账号";
}
export function sanitizeCaregiverRedirectPath(redirectTo?: string | null) {
return normalizeRedirectPath(redirectTo || undefined);
} }
export async function getCaregiverSession() { export async function getCaregiverSession() {
const sessionToken = (await cookies()).get(CAREGIVER_SESSION_COOKIE)?.value; const sessionToken = await getCurrentSessionToken();
if (!sessionToken) { if (!sessionToken) {
return null; return null;
} }
return upsertCaregiverByToken(sessionToken); const session = await prisma.caregiverSession.findUnique({
} where: { sessionToken },
include: {
caregiver: true,
},
});
export async function getOrCreateCaregiverSession() { if (!session) {
const cookieStore = await cookies(); return null;
let sessionToken = cookieStore.get(CAREGIVER_SESSION_COOKIE)?.value;
if (!sessionToken) {
sessionToken = createSessionToken();
cookieStore.set(CAREGIVER_SESSION_COOKIE, sessionToken, getCookieOptions());
} }
return upsertCaregiverByToken(sessionToken); return session.caregiver;
} }
export function createCaregiverSessionCookieValue() { export async function registerCaregiverAccount(input: {
return { username: string;
sessionToken: createSessionToken(), password: string;
options: getCookieOptions(), userAgent?: string | null;
}; }) {
const username = parseCaregiverUsername(input.username);
const password = parseCaregiverPassword(input.password);
const existingCaregiver = await prisma.caregiverAccount.findUnique({
where: { username },
});
if (existingCaregiver) {
throw new Error("这个用户名已经被注册了,请直接登录。");
}
const caregiver = await prisma.caregiverAccount.create({
data: {
username,
passwordHash: await hash(password, 10),
},
});
await createCaregiverSession({
caregiverId: caregiver.id,
userAgent: input.userAgent,
});
return caregiver;
}
export async function loginCaregiverAccount(input: {
username: string;
password: string;
userAgent?: string | null;
}) {
const username = parseCaregiverUsername(input.username);
const password = parseCaregiverPassword(input.password);
const caregiver = await prisma.caregiverAccount.findUnique({
where: { username },
});
if (!caregiver?.passwordHash) {
throw new Error("用户名或密码不正确。");
}
const passwordMatched = await compare(password, caregiver.passwordHash);
if (!passwordMatched) {
throw new Error("用户名或密码不正确。");
}
await createCaregiverSession({
caregiverId: caregiver.id,
userAgent: input.userAgent,
});
return caregiver;
}
export async function clearCurrentCaregiverSession() {
const sessionToken = await getCurrentSessionToken();
if (sessionToken) {
await prisma.caregiverSession.deleteMany({
where: {
sessionToken,
},
});
}
(await cookies()).delete(CAREGIVER_SESSION_COOKIE);
} }
+29 -1
View File
@@ -1,7 +1,35 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ turbopack: {
root: process.cwd(),
},
async headers() {
return [
{
source: "/sw.js",
headers: [
{
key: "Content-Type",
value: "application/javascript; charset=utf-8",
},
{
key: "Cache-Control",
value: "no-cache, no-store, must-revalidate",
},
],
},
{
source: "/manifest.webmanifest",
headers: [
{
key: "Cache-Control",
value: "public, max-age=0, must-revalidate",
},
],
},
];
},
}; };
export default nextConfig; export default nextConfig;
+6308
View File
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -6,29 +6,40 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"generate:icons": "node scripts/generate-icons.mjs"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.7.0", "@prisma/adapter-pg": "^7.7.0",
"@prisma/client": "^7.7.0", "@prisma/client": "^7.7.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"next": "16.2.4", "lucide-react": "^1.8.0",
"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",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"recharts": "^3.8.1",
"sharp": "^0.34.5",
"web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.4", "eslint-config-next": "16.2.4",
"prisma": "^7.7.0", "prisma": "^7.7.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
}, },
"optionalDependencies": {
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
"lightningcss-darwin-arm64": "1.32.0"
},
"ignoreScripts": [ "ignoreScripts": [
"sharp", "sharp",
"unrs-resolver" "unrs-resolver"
@@ -0,0 +1,35 @@
/*
Warnings:
- A unique constraint covering the columns `[publicId]` on the table `FamilyMessage` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "FamilyMessage" ADD COLUMN "publicId" SERIAL NOT NULL;
-- CreateTable
CREATE TABLE "PushSubscription" (
"id" TEXT NOT NULL,
"caregiverId" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"expirationTime" TIMESTAMP(3),
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
-- CreateIndex
CREATE INDEX "PushSubscription_caregiverId_updatedAt_idx" ON "PushSubscription"("caregiverId", "updatedAt" DESC);
-- CreateIndex
CREATE UNIQUE INDEX "FamilyMessage_publicId_key" ON "FamilyMessage"("publicId");
-- AddForeignKey
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_caregiverId_fkey" FOREIGN KEY ("caregiverId") REFERENCES "CaregiverAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,59 @@
/*
Warnings:
- A unique constraint covering the columns `[username]` on the table `CaregiverAccount` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "CaregiverAccount" ADD COLUMN "passwordHash" TEXT,
ADD COLUMN "username" TEXT;
-- CreateTable
CREATE TABLE "CaregiverSession" (
"id" TEXT NOT NULL,
"caregiverId" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userAgent" TEXT,
"lastSeenAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CaregiverSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CaregiverSession_sessionToken_key" ON "CaregiverSession"("sessionToken");
-- CreateIndex
CREATE INDEX "CaregiverSession_caregiverId_updatedAt_idx" ON "CaregiverSession"("caregiverId", "updatedAt" DESC);
-- CreateIndex
CREATE UNIQUE INDEX "CaregiverAccount_username_key" ON "CaregiverAccount"("username");
-- MigrateData
INSERT INTO "CaregiverSession" (
"id",
"caregiverId",
"sessionToken",
"lastSeenAt",
"createdAt",
"updatedAt"
)
SELECT
CONCAT('legacy_', md5("id" || ':' || "sessionToken")),
"id",
"sessionToken",
NOW(),
NOW(),
NOW()
FROM "CaregiverAccount"
WHERE "sessionToken" IS NOT NULL;
-- DropIndex
DROP INDEX "CaregiverAccount_sessionToken_key";
-- AlterTable
ALTER TABLE "CaregiverAccount" DROP COLUMN "sessionToken";
-- AddForeignKey
ALTER TABLE "CaregiverSession" ADD CONSTRAINT "CaregiverSession_caregiverId_fkey" FOREIGN KEY ("caregiverId") REFERENCES "CaregiverAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
ALTER TABLE "DeviceBinding"
ADD COLUMN IF NOT EXISTS "alias" TEXT;
@@ -0,0 +1,398 @@
-- CreateEnum
CREATE TYPE "FamilyRole" AS ENUM ('ADMIN', 'CAREGIVER', 'CARING');
-- CreateEnum
CREATE TYPE "MembershipStatus" AS ENUM ('ACTIVE', 'REMOVED');
-- CreateEnum
CREATE TYPE "InvitationStatus" AS ENUM ('PENDING', 'ACCEPTED', 'EXPIRED', 'REVOKED');
-- CreateEnum
CREATE TYPE "ContentVisibility" AS ENUM ('ELDER_ONLY', 'FAMILY_ONLY', 'FAMILY_AND_ELDER');
-- CreateEnum
CREATE TYPE "MemoPriority" AS ENUM ('NORMAL', 'IMPORTANT', 'PINNED');
-- CreateEnum
CREATE TYPE "CareTaskStatus" AS ENUM ('OPEN', 'CLAIMED', 'COMPLETED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "MedicationSource" AS ENUM ('PRESCRIPTION', 'PACKAGE_LABEL', 'FAMILY_ENTRY');
-- CreateEnum
CREATE TYPE "MedicationDoseStatus" AS ENUM ('SCHEDULED', 'REMINDING', 'TAKEN', 'SNOOZED', 'SKIPPED', 'MISSED', 'NEEDS_FAMILY_CONFIRMATION');
-- CreateEnum
CREATE TYPE "CheckInStatus" AS ENUM ('PENDING', 'SAFE', 'NEEDS_CONTACT', 'DECLINED', 'EXPIRED');
-- CreateEnum
CREATE TYPE "AlertLevel" AS ENUM ('RED', 'ORANGE', 'YELLOW', 'BLUE');
-- CreateEnum
CREATE TYPE "AlertStatus" AS ENUM ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'DISMISSED');
-- AlterTable
ALTER TABLE "ElderDevice" ADD COLUMN "deviceTokenHash" TEXT;
-- CreateTable
CREATE TABLE "ElderProfile" (
"id" TEXT NOT NULL,
"elderDeviceId" TEXT NOT NULL,
"preferredName" TEXT NOT NULL,
"timezone" TEXT NOT NULL DEFAULT 'Asia/Shanghai',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ElderProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FamilyCircle" (
"id" TEXT NOT NULL,
"elderProfileId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FamilyCircle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FamilyInvitation" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"role" "FamilyRole" NOT NULL DEFAULT 'CARING',
"relationLabel" TEXT,
"status" "InvitationStatus" NOT NULL DEFAULT 'PENDING',
"expiresAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FamilyInvitation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FamilyMembership" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"caregiverId" TEXT NOT NULL,
"role" "FamilyRole" NOT NULL DEFAULT 'CARING',
"relationLabel" TEXT,
"status" "MembershipStatus" NOT NULL DEFAULT 'ACTIVE',
"emergencyPush" BOOLEAN NOT NULL DEFAULT true,
"routinePush" BOOLEAN NOT NULL DEFAULT true,
"dutyOrder" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FamilyMembership_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FamilyPost" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"authorId" TEXT,
"content" TEXT NOT NULL,
"visibility" "ContentVisibility" NOT NULL DEFAULT 'FAMILY_AND_ELDER',
"elderAuthored" BOOLEAN NOT NULL DEFAULT false,
"pinned" BOOLEAN NOT NULL DEFAULT false,
"deliveredAt" TIMESTAMP(3),
"readAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FamilyPost_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FamilyMemo" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"priority" "MemoPriority" NOT NULL DEFAULT 'NORMAL',
"visibility" "ContentVisibility" NOT NULL DEFAULT 'FAMILY_AND_ELDER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FamilyMemo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CareTask" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"assignedToId" TEXT,
"title" TEXT NOT NULL,
"description" TEXT,
"dueAt" TIMESTAMP(3),
"status" "CareTaskStatus" NOT NULL DEFAULT 'OPEN',
"completedAt" TIMESTAMP(3),
"completionNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CareTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MedicationPlan" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"name" TEXT NOT NULL,
"dosage" TEXT NOT NULL,
"instructions" TEXT,
"timesJson" TEXT NOT NULL,
"source" "MedicationSource" NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3),
"stockCount" INTEGER,
"active" BOOLEAN NOT NULL DEFAULT true,
"pausedReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MedicationPlan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MedicationDose" (
"id" TEXT NOT NULL,
"medicationPlanId" TEXT NOT NULL,
"scheduledFor" TIMESTAMP(3) NOT NULL,
"status" "MedicationDoseStatus" NOT NULL DEFAULT 'SCHEDULED',
"respondedAt" TIMESTAMP(3),
"snoozedUntil" TIMESTAMP(3),
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MedicationDose_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CheckInRequest" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"requestedById" TEXT NOT NULL,
"prompt" TEXT,
"dueAt" TIMESTAMP(3),
"status" "CheckInStatus" NOT NULL DEFAULT 'PENDING',
"responseText" TEXT,
"respondedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CheckInRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SafetySignal" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"category" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"confidence" DOUBLE PRECISION,
"contextText" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SafetySignal_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SafetyAlert" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"safetySignalId" TEXT NOT NULL,
"claimedById" TEXT,
"level" "AlertLevel" NOT NULL,
"status" "AlertStatus" NOT NULL DEFAULT 'OPEN',
"title" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"resolutionNote" TEXT,
"claimedAt" TIMESTAMP(3),
"resolvedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SafetyAlert_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FamilyMemory" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"story" TEXT NOT NULL,
"mediaUrl" TEXT,
"occurredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FamilyMemory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ConsentSetting" (
"id" TEXT NOT NULL,
"elderProfileId" TEXT NOT NULL,
"shareTranscripts" BOOLEAN NOT NULL DEFAULT true,
"shareRawAudio" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ConsentSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"familyCircleId" TEXT NOT NULL,
"actorId" TEXT,
"action" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT,
"detailJson" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ElderProfile_elderDeviceId_key" ON "ElderProfile"("elderDeviceId");
-- CreateIndex
CREATE UNIQUE INDEX "FamilyCircle_elderProfileId_key" ON "FamilyCircle"("elderProfileId");
-- CreateIndex
CREATE UNIQUE INDEX "FamilyInvitation_tokenHash_key" ON "FamilyInvitation"("tokenHash");
-- CreateIndex
CREATE INDEX "FamilyInvitation_familyCircleId_status_expiresAt_idx" ON "FamilyInvitation"("familyCircleId", "status", "expiresAt");
-- CreateIndex
CREATE INDEX "FamilyMembership_caregiverId_status_idx" ON "FamilyMembership"("caregiverId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "FamilyMembership_familyCircleId_caregiverId_key" ON "FamilyMembership"("familyCircleId", "caregiverId");
-- CreateIndex
CREATE INDEX "FamilyPost_familyCircleId_createdAt_idx" ON "FamilyPost"("familyCircleId", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "FamilyMemo_familyCircleId_priority_updatedAt_idx" ON "FamilyMemo"("familyCircleId", "priority", "updatedAt" DESC);
-- CreateIndex
CREATE INDEX "CareTask_familyCircleId_status_dueAt_idx" ON "CareTask"("familyCircleId", "status", "dueAt");
-- CreateIndex
CREATE INDEX "MedicationPlan_familyCircleId_active_idx" ON "MedicationPlan"("familyCircleId", "active");
-- CreateIndex
CREATE INDEX "MedicationDose_scheduledFor_status_idx" ON "MedicationDose"("scheduledFor", "status");
-- CreateIndex
CREATE UNIQUE INDEX "MedicationDose_medicationPlanId_scheduledFor_key" ON "MedicationDose"("medicationPlanId", "scheduledFor");
-- CreateIndex
CREATE INDEX "CheckInRequest_familyCircleId_status_createdAt_idx" ON "CheckInRequest"("familyCircleId", "status", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "SafetySignal_familyCircleId_category_createdAt_idx" ON "SafetySignal"("familyCircleId", "category", "createdAt" DESC);
-- CreateIndex
CREATE UNIQUE INDEX "SafetyAlert_safetySignalId_key" ON "SafetyAlert"("safetySignalId");
-- CreateIndex
CREATE INDEX "SafetyAlert_familyCircleId_status_createdAt_idx" ON "SafetyAlert"("familyCircleId", "status", "createdAt" DESC);
-- CreateIndex
CREATE UNIQUE INDEX "ConsentSetting_elderProfileId_key" ON "ConsentSetting"("elderProfileId");
-- CreateIndex
CREATE INDEX "AuditLog_familyCircleId_createdAt_idx" ON "AuditLog"("familyCircleId", "createdAt" DESC);
-- AddForeignKey
ALTER TABLE "ElderProfile" ADD CONSTRAINT "ElderProfile_elderDeviceId_fkey" FOREIGN KEY ("elderDeviceId") REFERENCES "ElderDevice"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyCircle" ADD CONSTRAINT "FamilyCircle_elderProfileId_fkey" FOREIGN KEY ("elderProfileId") REFERENCES "ElderProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyCircle" ADD CONSTRAINT "FamilyCircle_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "CaregiverAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyInvitation" ADD CONSTRAINT "FamilyInvitation_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyInvitation" ADD CONSTRAINT "FamilyInvitation_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "CaregiverAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyMembership" ADD CONSTRAINT "FamilyMembership_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyMembership" ADD CONSTRAINT "FamilyMembership_caregiverId_fkey" FOREIGN KEY ("caregiverId") REFERENCES "CaregiverAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyPost" ADD CONSTRAINT "FamilyPost_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyPost" ADD CONSTRAINT "FamilyPost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "CaregiverAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyMemo" ADD CONSTRAINT "FamilyMemo_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyMemo" ADD CONSTRAINT "FamilyMemo_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "CaregiverAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CareTask" ADD CONSTRAINT "CareTask_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CareTask" ADD CONSTRAINT "CareTask_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "CaregiverAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CareTask" ADD CONSTRAINT "CareTask_assignedToId_fkey" FOREIGN KEY ("assignedToId") REFERENCES "CaregiverAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MedicationPlan" ADD CONSTRAINT "MedicationPlan_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MedicationPlan" ADD CONSTRAINT "MedicationPlan_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "CaregiverAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MedicationDose" ADD CONSTRAINT "MedicationDose_medicationPlanId_fkey" FOREIGN KEY ("medicationPlanId") REFERENCES "MedicationPlan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CheckInRequest" ADD CONSTRAINT "CheckInRequest_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CheckInRequest" ADD CONSTRAINT "CheckInRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "CaregiverAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SafetySignal" ADD CONSTRAINT "SafetySignal_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SafetyAlert" ADD CONSTRAINT "SafetyAlert_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SafetyAlert" ADD CONSTRAINT "SafetyAlert_safetySignalId_fkey" FOREIGN KEY ("safetySignalId") REFERENCES "SafetySignal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SafetyAlert" ADD CONSTRAINT "SafetyAlert_claimedById_fkey" FOREIGN KEY ("claimedById") REFERENCES "CaregiverAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyMemory" ADD CONSTRAINT "FamilyMemory_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConsentSetting" ADD CONSTRAINT "ConsentSetting_elderProfileId_fkey" FOREIGN KEY ("elderProfileId") REFERENCES "ElderProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_familyCircleId_fkey" FOREIGN KEY ("familyCircleId") REFERENCES "FamilyCircle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "CaregiverAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+371 -1
View File
@@ -45,14 +45,116 @@ enum ToolCallStatus {
FAILURE FAILURE
} }
enum FamilyRole {
ADMIN
CAREGIVER
CARING
}
enum MembershipStatus {
ACTIVE
REMOVED
}
enum InvitationStatus {
PENDING
ACCEPTED
EXPIRED
REVOKED
}
enum ContentVisibility {
ELDER_ONLY
FAMILY_ONLY
FAMILY_AND_ELDER
}
enum MemoPriority {
NORMAL
IMPORTANT
PINNED
}
enum CareTaskStatus {
OPEN
CLAIMED
COMPLETED
CANCELLED
}
enum MedicationSource {
PRESCRIPTION
PACKAGE_LABEL
FAMILY_ENTRY
}
enum MedicationDoseStatus {
SCHEDULED
REMINDING
TAKEN
SNOOZED
SKIPPED
MISSED
NEEDS_FAMILY_CONFIRMATION
}
enum CheckInStatus {
PENDING
SAFE
NEEDS_CONTACT
DECLINED
EXPIRED
}
enum AlertLevel {
RED
ORANGE
YELLOW
BLUE
}
enum AlertStatus {
OPEN
ACKNOWLEDGED
RESOLVED
DISMISSED
}
model CaregiverAccount { model CaregiverAccount {
id String @id @default(cuid()) id String @id @default(cuid())
sessionToken String @unique username String? @unique
passwordHash String?
nickname String? nickname String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sessions CaregiverSession[]
bindings DeviceBinding[] bindings DeviceBinding[]
messages FamilyMessage[] messages FamilyMessage[]
pushSubscriptions PushSubscription[]
createdCircles FamilyCircle[] @relation("CircleCreator")
memberships FamilyMembership[]
posts FamilyPost[]
memos FamilyMemo[]
createdTasks CareTask[] @relation("TaskCreator")
assignedTasks CareTask[] @relation("TaskAssignee")
medicationPlans MedicationPlan[]
requestedCheckIns CheckInRequest[]
claimedAlerts SafetyAlert[] @relation("AlertClaimer")
auditLogs AuditLog[]
createdInvitations FamilyInvitation[] @relation("InvitationCreator")
}
model CaregiverSession {
id String @id @default(cuid())
caregiverId String
sessionToken String @unique
userAgent String?
lastSeenAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade)
@@index([caregiverId, updatedAt(sort: Desc)])
} }
model ElderDevice { model ElderDevice {
@@ -68,12 +170,264 @@ model ElderDevice {
conversationTurns ConversationTurn[] conversationTurns ConversationTurn[]
usageEvents UsageEvent[] usageEvents UsageEvent[]
toolCalls ToolCallLog[] toolCalls ToolCallLog[]
deviceTokenHash String?
elderProfile ElderProfile?
}
model ElderProfile {
id String @id @default(cuid())
elderDeviceId String @unique
preferredName String
timezone String @default("Asia/Shanghai")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
elderDevice ElderDevice @relation(fields: [elderDeviceId], references: [id], onDelete: Cascade)
familyCircle FamilyCircle?
consentSetting ConsentSetting?
}
model FamilyCircle {
id String @id @default(cuid())
elderProfileId String @unique
createdById String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
elderProfile ElderProfile @relation(fields: [elderProfileId], references: [id], onDelete: Cascade)
createdBy CaregiverAccount @relation("CircleCreator", fields: [createdById], references: [id], onDelete: Restrict)
memberships FamilyMembership[]
posts FamilyPost[]
memos FamilyMemo[]
tasks CareTask[]
medicationPlans MedicationPlan[]
checkIns CheckInRequest[]
safetySignals SafetySignal[]
alerts SafetyAlert[]
memories FamilyMemory[]
auditLogs AuditLog[]
invitations FamilyInvitation[]
}
model FamilyInvitation {
id String @id @default(cuid())
familyCircleId String
createdById String
tokenHash String @unique
role FamilyRole @default(CARING)
relationLabel String?
status InvitationStatus @default(PENDING)
expiresAt DateTime
acceptedAt DateTime?
createdAt DateTime @default(now())
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
createdBy CaregiverAccount @relation("InvitationCreator", fields: [createdById], references: [id], onDelete: Cascade)
@@index([familyCircleId, status, expiresAt])
}
model FamilyMembership {
id String @id @default(cuid())
familyCircleId String
caregiverId String
role FamilyRole @default(CARING)
relationLabel String?
status MembershipStatus @default(ACTIVE)
emergencyPush Boolean @default(true)
routinePush Boolean @default(true)
dutyOrder Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade)
@@unique([familyCircleId, caregiverId])
@@index([caregiverId, status])
}
model FamilyPost {
id String @id @default(cuid())
familyCircleId String
authorId String?
content String
visibility ContentVisibility @default(FAMILY_AND_ELDER)
elderAuthored Boolean @default(false)
pinned Boolean @default(false)
deliveredAt DateTime?
readAt DateTime?
createdAt DateTime @default(now())
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
author CaregiverAccount? @relation(fields: [authorId], references: [id], onDelete: SetNull)
@@index([familyCircleId, createdAt(sort: Desc)])
}
model FamilyMemo {
id String @id @default(cuid())
familyCircleId String
authorId String
title String
content String
priority MemoPriority @default(NORMAL)
visibility ContentVisibility @default(FAMILY_AND_ELDER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
author CaregiverAccount @relation(fields: [authorId], references: [id], onDelete: Restrict)
@@index([familyCircleId, priority, updatedAt(sort: Desc)])
}
model CareTask {
id String @id @default(cuid())
familyCircleId String
createdById String
assignedToId String?
title String
description String?
dueAt DateTime?
status CareTaskStatus @default(OPEN)
completedAt DateTime?
completionNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
createdBy CaregiverAccount @relation("TaskCreator", fields: [createdById], references: [id], onDelete: Restrict)
assignedTo CaregiverAccount? @relation("TaskAssignee", fields: [assignedToId], references: [id], onDelete: SetNull)
@@index([familyCircleId, status, dueAt])
}
model MedicationPlan {
id String @id @default(cuid())
familyCircleId String
createdById String
name String
dosage String
instructions String?
timesJson String
source MedicationSource
startDate DateTime
endDate DateTime?
stockCount Int?
active Boolean @default(true)
pausedReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
createdBy CaregiverAccount @relation(fields: [createdById], references: [id], onDelete: Restrict)
doses MedicationDose[]
@@index([familyCircleId, active])
}
model MedicationDose {
id String @id @default(cuid())
medicationPlanId String
scheduledFor DateTime
status MedicationDoseStatus @default(SCHEDULED)
respondedAt DateTime?
snoozedUntil DateTime?
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
medicationPlan MedicationPlan @relation(fields: [medicationPlanId], references: [id], onDelete: Cascade)
@@unique([medicationPlanId, scheduledFor])
@@index([scheduledFor, status])
}
model CheckInRequest {
id String @id @default(cuid())
familyCircleId String
requestedById String
prompt String?
dueAt DateTime?
status CheckInStatus @default(PENDING)
responseText String?
respondedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
requestedBy CaregiverAccount @relation(fields: [requestedById], references: [id], onDelete: Restrict)
@@index([familyCircleId, status, createdAt(sort: Desc)])
}
model SafetySignal {
id String @id @default(cuid())
familyCircleId String
category String
summary String
confidence Float?
contextText String?
createdAt DateTime @default(now())
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
alert SafetyAlert?
@@index([familyCircleId, category, createdAt(sort: Desc)])
}
model SafetyAlert {
id String @id @default(cuid())
familyCircleId String
safetySignalId String @unique
claimedById String?
level AlertLevel
status AlertStatus @default(OPEN)
title String
summary String
resolutionNote String?
claimedAt DateTime?
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
safetySignal SafetySignal @relation(fields: [safetySignalId], references: [id], onDelete: Cascade)
claimedBy CaregiverAccount? @relation("AlertClaimer", fields: [claimedById], references: [id], onDelete: SetNull)
@@index([familyCircleId, status, createdAt(sort: Desc)])
}
model FamilyMemory {
id String @id @default(cuid())
familyCircleId String
title String
story String
mediaUrl String?
occurredAt DateTime?
createdAt DateTime @default(now())
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
}
model ConsentSetting {
id String @id @default(cuid())
elderProfileId String @unique
shareTranscripts Boolean @default(true)
shareRawAudio Boolean @default(false)
updatedAt DateTime @updatedAt
elderProfile ElderProfile @relation(fields: [elderProfileId], references: [id], onDelete: Cascade)
}
model AuditLog {
id String @id @default(cuid())
familyCircleId String
actorId String?
action String
entityType String
entityId String?
detailJson String?
createdAt DateTime @default(now())
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
actor CaregiverAccount? @relation(fields: [actorId], references: [id], onDelete: SetNull)
@@index([familyCircleId, createdAt(sort: Desc)])
} }
model DeviceBinding { model DeviceBinding {
id String @id @default(cuid()) id String @id @default(cuid())
caregiverId String caregiverId String
elderDeviceId String elderDeviceId String
alias String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade) caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade)
elderDevice ElderDevice @relation(fields: [elderDeviceId], references: [id], onDelete: Cascade) elderDevice ElderDevice @relation(fields: [elderDeviceId], references: [id], onDelete: Cascade)
@@ -84,6 +438,7 @@ model DeviceBinding {
model FamilyMessage { model FamilyMessage {
id String @id @default(cuid()) id String @id @default(cuid())
publicId Int @unique @default(autoincrement())
elderDeviceId String elderDeviceId String
caregiverId String? caregiverId String?
direction MessageDirection direction MessageDirection
@@ -102,6 +457,21 @@ model FamilyMessage {
@@index([direction, status, createdAt(sort: Desc)]) @@index([direction, status, createdAt(sort: Desc)])
} }
model PushSubscription {
id String @id @default(cuid())
caregiverId String
endpoint String @unique
p256dh String
auth String
expirationTime DateTime?
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade)
@@index([caregiverId, updatedAt(sort: Desc)])
}
model ConversationTurn { model ConversationTurn {
id String @id @default(cuid()) id String @id @default(cuid())
elderDeviceId String elderDeviceId String
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+66
View File
@@ -0,0 +1,66 @@
self.addEventListener("push", (event) => {
let data = {
title: "安智伴 家人连线台",
body: "收到一条新的家人留言。",
url: "/",
icon: "/pwa/icon-192x192.webp",
badge: "/pwa/badge-96x96.webp",
tag: "digital-human-message",
};
if (event.data) {
try {
data = { ...data, ...event.data.json() };
} catch {
data = { ...data, body: event.data.text() };
}
}
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon,
badge: data.badge,
tag: data.tag,
data: {
url: data.url,
},
}),
);
});
self.addEventListener("notificationclick", (event) => {
const targetUrl = new URL(
event.notification.data?.url || "/",
self.location.origin,
).toString();
event.notification.close();
event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true }).then(
async (clientList) => {
for (const client of clientList) {
if (!("focus" in client)) {
continue;
}
if (client.url === targetUrl) {
return client.focus();
}
if (client.url.startsWith(self.location.origin) && "navigate" in client) {
await client.navigate(targetUrl);
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
return undefined;
},
),
);
});
+109
View File
@@ -0,0 +1,109 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const sourceIconPath = path.join(projectRoot, "app", "icon.png");
const pwaOutputDir = path.join(projectRoot, "public", "pwa");
const androidResArg = process.argv.find((arg) =>
arg.startsWith("--android-res-dir="),
);
const androidResDir = androidResArg
? androidResArg.slice("--android-res-dir=".length)
: process.env.ANDROID_RES_DIR;
function createCircleMask(size) {
return Buffer.from(`
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
<circle cx="${size / 2}" cy="${size / 2}" r="${size / 2}" fill="white" />
</svg>
`);
}
async function writeWebp(size, outputPath) {
await sharp(sourceIconPath)
.resize(size, size)
.webp({ quality: 88, effort: 6 })
.toFile(outputPath);
}
async function writeMaskableWebp(size, outputPath) {
const foreground = await sharp(sourceIconPath)
.resize(Math.round(size * 0.82), Math.round(size * 0.82), {
fit: "contain",
})
.webp({ quality: 88, effort: 6 })
.toBuffer();
await sharp({
create: {
width: size,
height: size,
channels: 4,
background: "#FBFAF6",
},
})
.composite([
{
input: foreground,
gravity: "center",
},
])
.webp({ quality: 88, effort: 6 })
.toFile(outputPath);
}
async function writeAndroidIcons(resDir) {
const densities = [
{ dir: "mipmap-mdpi", size: 48 },
{ dir: "mipmap-hdpi", size: 72 },
{ dir: "mipmap-xhdpi", size: 96 },
{ dir: "mipmap-xxhdpi", size: 144 },
{ dir: "mipmap-xxxhdpi", size: 192 },
];
for (const density of densities) {
const targetDir = path.join(resDir, density.dir);
await mkdir(targetDir, { recursive: true });
const squareBuffer = await sharp(sourceIconPath)
.resize(density.size, density.size)
.png()
.toBuffer();
await sharp(squareBuffer)
.webp({ quality: 92 })
.toFile(path.join(targetDir, "ic_launcher.webp"));
await sharp(squareBuffer)
.composite([
{
input: createCircleMask(density.size),
blend: "dest-in",
},
])
.webp({ quality: 92 })
.toFile(path.join(targetDir, "ic_launcher_round.webp"));
}
}
await mkdir(pwaOutputDir, { recursive: true });
await Promise.all([
writeWebp(192, path.join(pwaOutputDir, "icon-192x192.webp")),
writeWebp(512, path.join(pwaOutputDir, "icon-512x512.webp")),
writeWebp(180, path.join(pwaOutputDir, "apple-touch-icon.webp")),
writeWebp(96, path.join(pwaOutputDir, "badge-96x96.webp")),
writeMaskableWebp(512, path.join(pwaOutputDir, "icon-maskable-512x512.webp")),
]);
if (androidResDir) {
await writeAndroidIcons(androidResDir);
}
console.log("Icons generated successfully.");