Compare commits

..
21 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
89 changed files with 12135 additions and 2152 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/) |
+28 -100
View File
@@ -1,113 +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 { 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() {
export default async function ActivityPage({ searchParams }: PageProps<"/activity">) {
const caregiver = await requireCaregiverSession("/activity");
const { usageEvents, conversationTurns, toolCalls } = await getCaregiverActivity(
caregiver.id,
);
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="活动记录独立成页,回看陪伴过程更清楚。"
description="这里专门收下老人端的使用事件、最近对话片段和工具调用,不再和留言流混排。"
caregiverToken={caregiver.sessionToken}
>
{usageEvents.length === 0 && conversationTurns.length === 0 && toolCalls.length === 0 ? (
<section className="rounded-[32px] border border-dashed border-[var(--line)] bg-white/72 px-6 py-10 text-center shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
<h2 className="font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
还没有活动记录
</h2>
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
等老人端开始对话、切换镜头或触发桥接工具后,这里会自动累积活动时间线。
</p>
<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>
) : (
<section className="grid gap-6 xl:grid-cols-3">
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
使用事件
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
老人端操作轨迹
</h2>
<div className="mt-5 space-y-3">
{usageEvents.map((event) => (
<div key={event.id} className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
<span>{getDeviceName(event.elderDevice.displayName)}</span>
<span>{formatDateTime(event.createdAt)}</span>
</div>
<p className="mt-2 text-sm leading-7 text-[var(--ink)]">
{getUsageLabel(event.eventType)}
</p>
</div>
))}
</div>
</div>
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
对话片段
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
最近说了什么
</h2>
<div className="mt-5 space-y-3">
{conversationTurns.map((turn) => (
<div key={turn.id} className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
<span>{getConversationRoleLabel(turn.role)}</span>
<span>{formatDateTime(turn.createdAt)}</span>
</div>
<p className="mt-2 text-xs text-[var(--muted)]">
{getDeviceName(turn.elderDevice.displayName)}
</p>
<p className="mt-2 text-sm leading-7 text-[var(--ink)]">
{truncateText(turn.content, 132)}
</p>
</div>
))}
</div>
</div>
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
工具调用
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
function calling 执行记录
</h2>
<div className="mt-5 space-y-3">
{toolCalls.map((log) => (
<div key={log.id} className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
<span>{getDeviceName(log.elderDevice.displayName)}</span>
<span>{formatDateTime(log.createdAt)}</span>
</div>
<p className="mt-2 text-sm font-semibold text-[var(--ink)]">
{log.toolName}
</p>
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
{truncateText(log.outputText || log.argumentsJson, 136)}
</p>
</div>
))}
</div>
</div>
</section>
)}
<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 { recordConversationTurns } from "@/lib/monitor-data";
import { requireDeviceAuth } from "@/lib/device-auth";
function toConversationRole(rawRole: unknown) {
switch (rawRole) {
@@ -55,6 +56,7 @@ export async function POST(request: Request) {
}
try {
await requireDeviceAuth(request, body.deviceUuid);
const result = await recordConversationTurns({
deviceUuid: body.deviceUuid,
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 { markFamilyMessagesAsRead } from "@/lib/monitor-data";
import { requireDeviceAuth } from "@/lib/device-auth";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
@@ -24,6 +25,7 @@ export async function POST(request: Request) {
: undefined;
try {
await requireDeviceAuth(request, body.deviceUuid);
await markFamilyMessagesAsRead(body.deviceUuid, messageIds);
return NextResponse.json({ ok: true });
+2
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { listFamilyMessagesForDevice } from "@/lib/monitor-data";
import { requireDeviceAuth } from "@/lib/device-auth";
export async function GET(request: NextRequest) {
const rawDeviceValue =
@@ -20,6 +21,7 @@ export async function GET(request: NextRequest) {
}
try {
await requireDeviceAuth(request, rawDeviceValue);
const data = await listFamilyMessagesForDevice(rawDeviceValue, take);
return NextResponse.json({
+13
View File
@@ -1,6 +1,8 @@
import { NextResponse } from "next/server";
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) {
const body = (await request.json().catch(() => null)) as
@@ -8,6 +10,7 @@ export async function POST(request: Request) {
deviceUuid?: string;
displayName?: string;
appVersion?: string;
deviceToken?: string;
}
| null;
@@ -26,10 +29,20 @@ export async function POST(request: Request) {
appVersion:
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({
deviceUuid: device.deviceUuid,
bindUrl: createBindUrl(device.deviceUuid),
deviceToken,
});
} catch (error) {
return NextResponse.json(
+2
View File
@@ -2,6 +2,7 @@ import { ToolCallStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { recordToolCall } from "@/lib/monitor-data";
import { requireDeviceAuth } from "@/lib/device-auth";
function toToolCallStatus(rawStatus: unknown) {
return rawStatus === ToolCallStatus.FAILURE
@@ -49,6 +50,7 @@ export async function POST(request: Request) {
}
try {
await requireDeviceAuth(request, body.deviceUuid);
const log = await recordToolCall({
deviceUuid: body.deviceUuid,
toolName: body.toolName,
+2
View File
@@ -2,6 +2,7 @@ import { UsageEventType } from "@prisma/client";
import { NextResponse } from "next/server";
import { recordUsageEvent } from "@/lib/monitor-data";
import { requireDeviceAuth } from "@/lib/device-auth";
function toUsageEventType(rawType: unknown) {
switch (rawType) {
@@ -46,6 +47,7 @@ export async function POST(request: Request) {
}
try {
await requireDeviceAuth(request, body.deviceUuid);
await recordUsageEvent({
deviceUuid: body.deviceUuid,
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 { bindDeviceToCaregiver, createBindUrl } from "@/lib/monitor-data";
import { getOrCreateCaregiverSession } from "@/lib/session";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
@@ -18,7 +18,15 @@ export async function POST(request: Request) {
}
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);
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 },
);
}
}
+10 -2
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { createFamilyMessageFromCaregiver } from "@/lib/monitor-data";
import { getOrCreateCaregiverSession } from "@/lib/session";
import { getCaregiverSession } from "@/lib/session";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
@@ -27,7 +27,15 @@ export async function POST(request: Request) {
}
try {
const caregiver = await getOrCreateCaregiverSession();
const caregiver = await getCaregiverSession();
if (!caregiver) {
return NextResponse.json(
{ error: "请先登录账号后再发送留言。" },
{ status: 401 },
);
}
const message = await createFamilyMessageFromCaregiver({
caregiverId: caregiver.id,
rawDeviceValue: body.deviceUuid,
+8 -35
View File
@@ -1,49 +1,22 @@
import { type NextRequest, NextResponse } from "next/server";
import {
CAREGIVER_SESSION_COOKIE,
createCaregiverSessionCookieValue,
buildCaregiverLoginPath,
getCaregiverSession,
sanitizeCaregiverRedirectPath,
} from "@/lib/session";
import { prisma } from "@/lib/prisma";
function normalizeRedirectPath(redirectTo?: string | null) {
if (!redirectTo || !redirectTo.startsWith("/") || redirectTo.startsWith("//")) {
return "/";
}
return redirectTo;
}
export async function GET(request: NextRequest) {
const redirectTo = normalizeRedirectPath(
const redirectTo = sanitizeCaregiverRedirectPath(
request.nextUrl.searchParams.get("redirectTo"),
);
const existingCaregiver = await getCaregiverSession();
const response = new NextResponse(null, {
const caregiver = await getCaregiverSession();
const location = caregiver ? redirectTo : buildCaregiverLoginPath(redirectTo);
return new NextResponse(null, {
status: 307,
headers: {
Location: redirectTo,
Location: location,
},
});
if (existingCaregiver) {
return response;
}
const { sessionToken, options } = createCaregiverSessionCookieValue();
await prisma.caregiverAccount.upsert({
where: { sessionToken },
update: {},
create: { sessionToken },
});
response.cookies.set({
name: CAREGIVER_SESSION_COOKIE,
value: sessionToken,
...options,
});
return response;
}
+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 }); }
}
+10 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { getOrCreateCaregiverSession } from "@/lib/session";
import { getCaregiverSession } from "@/lib/session";
import { savePushSubscription } from "@/lib/push";
export async function POST(request: Request) {
@@ -50,7 +50,15 @@ export async function POST(request: Request) {
};
try {
const caregiver = await getOrCreateCaregiverSession();
const caregiver = await getCaregiverSession();
if (!caregiver) {
return NextResponse.json(
{ error: "请先登录账号后再开启消息提醒。" },
{ status: 401 },
);
}
await savePushSubscription({
caregiverId: caregiver.id,
subscription,
+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 },
);
}
}
+54 -13
View File
@@ -3,7 +3,7 @@ import { redirect } from "next/navigation";
import { bindDeviceToCaregiver } from "@/lib/monitor-data";
import {
buildCaregiverSessionBootstrapPath,
buildCaregiverLoginPath,
getCaregiverSession,
} 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">
<section className="w-full rounded-[36px] border border-white/70 bg-white/82 p-8 text-center shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
连接家人设备
连接设备
</p>
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
还没有收到设备码
还没有收到设备信息
</h1>
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
请让老人打开家人连接页,把二维码给您扫一下,或者把设备码复制过来。
请让长辈打开手机上的「家人连接」页面,把二维码给您扫一下,或将设备码复制过来。
</p>
<div className="mt-6 flex justify-center gap-3">
<Link
@@ -46,7 +46,7 @@ export default async function BindPage({ searchParams }: BindPageProps) {
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"
>
回到设备页
返回设备页
</Link>
</div>
</section>
@@ -58,29 +58,70 @@ export default async function BindPage({ searchParams }: BindPageProps) {
if (!caregiver) {
redirect(
buildCaregiverSessionBootstrapPath(
buildCaregiverLoginPath(
`/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 (
<main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6">
<section className="w-full rounded-[36px] border border-white/70 bg-white/84 p-8 shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
绑定完成
连接成功
</p>
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
这位老人已经连到当前家属账号
已成功连接长辈的设备
</h1>
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
现在您可以在控制台查看老人给家里的留言、最近陪伴记录,也能马上给老人写下一句问候。
现在可以查看长辈的留言、了解使用动态,也可以随时给长辈写一句问候。
</p>
<div className="mt-6 rounded-[28px] bg-[var(--paper-soft)] p-5">
<p className="text-sm text-[var(--muted)]">设备 UUID</p>
<p className="text-sm text-[var(--muted)]">设备标识</p>
<p className="mt-2 break-all text-lg font-semibold text-[var(--ink)]">
{device.deviceUuid}
</p>
@@ -91,13 +132,13 @@ export default async function BindPage({ searchParams }: BindPageProps) {
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)]"
>
打开这台设备页
查看设备详情
</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>
+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>
);
}
+30 -195
View File
@@ -1,220 +1,55 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { SendMessageForm } from "@/components/dashboard-actions";
import { ManageDeviceForm, SendMessageForm } from "@/components/dashboard-actions";
import { PanelShell } from "@/components/panel-shell";
import { getCaregiverDeviceDetail } from "@/lib/caregiver-panel";
import {
formatDateTime,
getConversationRoleLabel,
getDeviceName,
getImportanceLabel,
getMessageHref,
getUsageLabel,
truncateText,
} from "@/lib/panel-format";
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";
type DeviceDetailPageProps = {
params: Promise<{ deviceUuid: string }>;
};
export default async function DeviceDetailPage({ params }: DeviceDetailPageProps) {
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 elderMessages = binding.elderDevice.messages.filter(
(message) => message.direction === "ELDER_TO_FAMILY",
);
const familyMessages = binding.elderDevice.messages.filter(
(message) => message.direction === "FAMILY_TO_ELDER",
);
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.elderDevice.displayName)}
description="这是一台设备的专属页面。给老人写问候、查看这台设备最近的留言、活动和对话,都在这里完成。"
caregiverToken={caregiver.sessionToken}
>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
<p className="text-sm text-[var(--muted)]">老人新留言</p>
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
{binding.elderUnreadCount}
</p>
</div>
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
<p className="text-sm text-[var(--muted)]">待老人查看</p>
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
{binding.familyUnreadCount}
</p>
</div>
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
<p className="text-sm text-[var(--muted)]">最近在线</p>
<p className="mt-3 text-lg font-semibold text-[var(--ink)]">
{formatDateTime(binding.elderDevice.lastSeenAt)}
</p>
</div>
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
<p className="text-sm text-[var(--muted)]">绑定链接</p>
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
{truncateText(binding.bindUrl, 52)}
</p>
</div>
<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>
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<div className="space-y-6">
<SendMessageForm deviceUuid={binding.elderDevice.deviceUuid} />
<section className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
老人给家里的话
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
最近通过桥接工具送出的内容
</h2>
<div 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>
<Link
href="/messages"
className="inline-flex h-11 items-center justify-center rounded-full border border-[var(--line)] bg-white px-4 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
>
去留言中心
<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>
<div className="mt-5 space-y-3">
{elderMessages.length === 0 ? (
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
这台设备暂时还没有新的老人留言。
</p>
) : (
elderMessages.map((message) => (
<Link
key={message.id}
href={getMessageHref(message.publicId)}
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
>
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
<span className="rounded-full bg-white px-3 py-1 font-semibold text-[var(--ink)]">
#{message.publicId}
</span>
<span>{getImportanceLabel(message.importance)}</span>
<span>{formatDateTime(message.createdAt)}</span>
</div>
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
{truncateText(message.content, 112)}
</p>
</Link>
))
)}
</div>
)) : <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>
<section className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
最近活动
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
这台设备刚刚发生了什么
</h2>
<div className="mt-5 space-y-3">
{binding.elderDevice.usageEvents.length === 0 ? (
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
暂时还没有同步到新的使用事件。
</p>
) : (
binding.elderDevice.usageEvents.map((event) => (
<div key={event.id} className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
<span>{getUsageLabel(event.eventType)}</span>
<span>{formatDateTime(event.createdAt)}</span>
</div>
</div>
))
)}
</div>
</section>
</div>
<div className="space-y-6">
<section className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
家属给老人的问候
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
最近写给这位老人的内容
</h2>
<div className="mt-5 space-y-3">
{familyMessages.length === 0 ? (
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
还没有发给这位老人的问候。
</p>
) : (
familyMessages.map((message) => (
<Link
key={message.id}
href={getMessageHref(message.publicId)}
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
>
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
<span className="rounded-full bg-white px-3 py-1 font-semibold text-[var(--ink)]">
#{message.publicId}
</span>
<span>{getImportanceLabel(message.importance)}</span>
<span>{formatDateTime(message.createdAt)}</span>
</div>
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
{truncateText(message.content, 112)}
</p>
</Link>
))
)}
</div>
</section>
<section className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
最近对话片段
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
快速回看陪伴内容
</h2>
<div className="mt-5 space-y-3">
{binding.elderDevice.conversationTurns.length === 0 ? (
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
这台设备最近还没有同步到对话片段。
</p>
) : (
binding.elderDevice.conversationTurns.map((turn) => (
<div key={turn.id} className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
<div className="flex items-center justify-between gap-3 text-xs text-[var(--muted)]">
<span>{getConversationRoleLabel(turn.role)}</span>
<span>{formatDateTime(turn.createdAt)}</span>
</div>
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
{truncateText(turn.content, 124)}
</p>
</div>
))
)}
</div>
</section>
</div>
</section>
<details className="device-manage"><summary className="btn block">设备设置</summary><ManageDeviceForm deviceUuid={binding.elderDevice.deviceUuid} initialAlias={binding.alias} deviceLabel={getDeviceName(binding.elderDevice.displayName)} /></details>
</PanelShell>
);
}
+32 -126
View File
@@ -1,146 +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 { PwaControls } from "@/components/pwa-controls";
import { getCaregiverDevices } from "@/lib/caregiver-panel";
import { formatDateTime, getDeviceName, truncateText } from "@/lib/panel-format";
import { formatDateTime, getDeviceName } from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
import { 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="设备管理和绑定入口单独放在这里。"
description="手动粘贴设备码、扫码绑定、查看某台设备的专属页,都统一放在设备页,不再和首页摘要、活动记录混在一起。"
caregiverToken={caregiver.sessionToken}
actions={<PwaControls />}
>
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<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 />
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
设备入口说明
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
一台设备一个详情页
</h2>
<div className="mt-4 space-y-3 rounded-[24px] bg-[var(--paper-soft)] p-5 text-sm leading-7 text-[var(--muted)]">
<p>1. 先通过扫码或粘贴设备码建立连接。</p>
<p>2. 进入设备详情页给这位老人写留言,不再在首页里混排。</p>
<p>3. 每台设备都有自己的最近留言、对话片段和工具调用摘要。</p>
<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>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/scan"
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--olive)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--olive-deep)]"
>
打开扫码页
</Link>
<Link
href="/messages"
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
>
去留言中心
</Link>
</div>
</div>
</section>
{devices.length === 0 ? (
<section className="rounded-[32px] border border-dashed border-[var(--line)] bg-white/72 px-6 py-10 text-center shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
<h2 className="font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
还没有绑定设备
</h2>
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
等待绑定成功后,每台设备都会在这里生成自己的专属入口,后续写留言和查看状态都从设备详情页进入。
</p>
</section>
) : (
<section className="grid gap-6 lg:grid-cols-2">
{devices.map((binding) => {
const latestMessage = binding.elderDevice.messages[0];
return (
<article
key={binding.id}
className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]"
>
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
设备详情
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
{getDeviceName(binding.elderDevice.displayName)}
</h2>
<div className="mt-4 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
最近在线:{formatDateTime(binding.elderDevice.lastSeenAt)}
</span>
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
老人新留言 {binding.elderUnreadCount}
</span>
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
待老人查看 {binding.familyUnreadCount}
</span>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-3">
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
<p className="text-xs text-[var(--muted)]">留言总数</p>
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
{binding.elderDevice._count.messages}
</p>
</div>
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
<p className="text-xs text-[var(--muted)]">对话条数</p>
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
{binding.elderDevice._count.conversationTurns}
</p>
</div>
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
<p className="text-xs text-[var(--muted)]">工具调用</p>
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
{binding.elderDevice._count.toolCalls}
</p>
</div>
</div>
<div className="mt-5 rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
<p className="text-sm font-semibold text-[var(--ink)]">最近一条动态</p>
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
{latestMessage
? truncateText(latestMessage.content, 96)
: "这台设备还没有同步到新的留言或问候。"}
</p>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href={`/devices/${binding.elderDevice.deviceUuid}`}
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
>
打开设备页
</Link>
<Link
href="/messages"
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
>
去留言中心
</Link>
</div>
</article>
);
})}
</section>
)}
</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";
:root {
--paper: #f4ece0;
--paper-soft: #fbf7f1;
--paper-strong: #fffdf8;
--ink: #241b14;
--ink-soft: #3d2d20;
--muted: #726153;
--line: rgba(85, 60, 42, 0.15);
--copper: #c76733;
--copper-deep: #964722;
--olive: #76845e;
--olive-deep: #596946;
--canvas: #e7e4df;
--paper: #f5f4f2;
--surface: #ffffff;
--surface-soft: #faf9f8;
--ink: #211e1a;
--ink-soft: #3d3833;
--muted: #8b857c;
--muted-light: #b5afa6;
--line: rgba(33, 30, 26, 0.08);
--line-strong: rgba(33, 30, 26, 0.15);
--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 {
@@ -20,47 +34,176 @@
--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 {
background: #ecdfcf;
@media (min-width: 700px) {
.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 {
color: var(--ink);
font-family: var(--font-body), sans-serif;
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.82), transparent 34%),
radial-gradient(circle at 88% 10%, rgba(205, 126, 76, 0.22), transparent 22%),
linear-gradient(180deg, #f6eee1 0%, #efe2d1 52%, #e8d6c3 100%);
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
textarea,
select {
font: inherit;
}
::selection {
background: rgba(199, 103, 51, 0.18);
}
.paper-panel {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 249, 241, 0.84)),
repeating-linear-gradient(
135deg,
rgba(198, 163, 127, 0.06),
rgba(198, 163, 127, 0.06) 10px,
rgba(255, 255, 255, 0.08) 10px,
rgba(255, 255, 255, 0.08) 20px
);
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
+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>
);
}
+15 -31
View File
@@ -1,55 +1,37 @@
import type { Metadata, Viewport } from "next";
import { Noto_Sans_SC, Noto_Serif_SC } from "next/font/google";
import "./globals.css";
const bodyFont = Noto_Sans_SC({
variable: "--font-body",
subsets: ["latin"],
weight: ["400", "500", "700"],
});
const displayFont = Noto_Serif_SC({
variable: "--font-display",
subsets: ["latin"],
weight: ["400", "600", "700"],
});
import { NavigationProgressProvider } from "@/components/navigation-progress";
export const metadata: Metadata = {
metadataBase: new URL("https://digital-human.xn--876a.net"),
title: "家人连线台",
description: "给老人设备做绑定、查看留言、追踪陪伴记录的家属端。",
title: "安智伴 家人连线台",
description: "随时了解长辈的近况,查看留言、使用动态,给长辈写问候。",
manifest: "/manifest.webmanifest",
icons: {
icon: [
{
url: "/pwa/icon-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
url: "/pwa/icon-512x512.png",
icon: {
url: "/app-icon.webp",
sizes: "512x512",
type: "image/png",
type: "image/webp",
},
],
apple: [
{
url: "/pwa/apple-touch-icon.png",
url: "/pwa/apple-touch-icon.webp",
sizes: "180x180",
type: "image/png",
type: "image/webp",
},
],
shortcut: "/pwa/icon-192x192.png",
shortcut: "/app-icon.webp",
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "家人连线台",
title: "安智伴 家人连线台",
},
};
export const viewport: Viewport = {
themeColor: "#35548d",
themeColor: "#F5F4F2",
};
export default function RootLayout({
@@ -58,8 +40,10 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" className={`${bodyFont.variable} ${displayFont.variable}`}>
<body className="min-h-screen">{children}</body>
<html lang="zh-CN">
<body className="min-h-screen">
<NavigationProgressProvider>{children}</NavigationProgressProvider>
</body>
</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>
);
}
+10 -10
View File
@@ -2,30 +2,30 @@ import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "家人连线台",
short_name: "家人连线台",
description: "给老人设备做绑定、查看留言、追踪陪伴记录的家属端。",
name: "安智伴 家人连线台",
short_name: "安智伴 家人连线台",
description: "随时了解长辈的近况,查看留言、使用动态,给长辈写问候。",
start_url: "/",
display: "standalone",
background_color: "#f6eee1",
theme_color: "#35548d",
theme_color: "#FBFAF6",
lang: "zh-CN",
categories: ["utilities", "lifestyle", "health"],
icons: [
{
src: "/pwa/icon-192x192.png",
src: "/pwa/icon-192x192.webp",
sizes: "192x192",
type: "image/png",
type: "image/webp",
},
{
src: "/pwa/icon-512x512.png",
src: "/pwa/icon-512x512.webp",
sizes: "512x512",
type: "image/png",
type: "image/webp",
},
{
src: "/pwa/icon-maskable-512x512.png",
src: "/pwa/icon-maskable-512x512.webp",
sizes: "512x512",
type: "image/png",
type: "image/webp",
purpose: "maskable",
},
],
+18 -101
View File
@@ -3,118 +3,35 @@ import { notFound } from "next/navigation";
import { PanelShell } from "@/components/panel-shell";
import { getCaregiverMessageDetail } from "@/lib/caregiver-panel";
import {
formatDateTime,
getDeviceName,
getImportanceLabel,
getMessageDirectionLabel,
getMessageStatusLabel,
} from "@/lib/panel-format";
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";
type MessageDetailPageProps = {
params: Promise<{ messageId: string }>;
};
export default async function MessageDetailPage({ params }: MessageDetailPageProps) {
const { messageId } = await params;
const publicId = Number.parseInt(messageId, 10);
if (!Number.isFinite(publicId) || publicId <= 0) {
notFound();
}
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();
}
if (!message) notFound();
return (
<PanelShell
currentPath="/messages"
title={`留言 #${message.publicId}`}
description="这是单条留言详情页。无论从通知点击进来,还是从留言中心点开,都能在这里看到完整内容与所属设备信息。"
caregiverToken={caregiver.sessionToken}
>
<section className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<article className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<div className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted)]">
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1 font-semibold text-[var(--ink)]">
#{message.publicId}
</span>
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
{getMessageDirectionLabel(message.direction)}
</span>
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
{getImportanceLabel(message.importance)}
</span>
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
{getMessageStatusLabel(message)}
</span>
<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>
<h2 className="mt-5 font-[family-name:var(--font-display)] text-4xl leading-[1.2] text-[var(--ink)]">
{message.content}
</h2>
{message.recipientRelation ? (
<p className="mt-4 text-sm leading-7 text-[var(--muted)]">
目标对象:{message.recipientRelation}
</p>
) : null}
<p style={{ margin: "12px 0 0", fontSize: 17, lineHeight: 1.65 }}>{message.content}</p>
</article>
<aside className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
留言信息
</p>
<div className="mt-5 space-y-4 text-sm leading-7 text-[var(--muted)]">
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
<p>所属设备</p>
<p className="mt-2 font-semibold text-[var(--ink)]">
{getDeviceName(message.elderDevice.displayName)}
</p>
</div>
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
<p>设备 UUID</p>
<p className="mt-2 break-all font-semibold text-[var(--ink)]">
{message.elderDevice.deviceUuid}
</p>
</div>
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
<p>创建时间</p>
<p className="mt-2 font-semibold text-[var(--ink)]">
{formatDateTime(message.createdAt)}
</p>
</div>
<div className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
<p>最近在线</p>
<p className="mt-2 font-semibold text-[var(--ink)]">
{formatDateTime(message.elderDevice.lastSeenAt)}
</p>
</div>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/messages"
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
>
回到留言中心
</Link>
<Link
href={`/devices/${message.elderDevice.deviceUuid}`}
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
>
打开这台设备页
</Link>
</div>
</aside>
<section 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>
);
}
+25 -128
View File
@@ -1,146 +1,43 @@
import Link from "next/link";
import { PenLine } from "lucide-react";
import { PanelShell } from "@/components/panel-shell";
import { PwaControls } from "@/components/pwa-controls";
import { getCaregiverMessages } from "@/lib/caregiver-panel";
import {
formatDateTime,
getDeviceName,
getImportanceLabel,
getMessageDirectionLabel,
getMessageHref,
getMessageStatusLabel,
truncateText,
} from "@/lib/panel-format";
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() {
export default async function MessagesPage({ searchParams }: PageProps<"/messages">) {
const caregiver = await requireCaregiverSession("/messages");
const { messages, unreadIncomingCount, unreadOutgoingCount } =
await getCaregiverMessages(caregiver.id);
const incomingMessages = messages.filter(
(message) => message.direction === "ELDER_TO_FAMILY",
);
const outgoingMessages = messages.filter(
(message) => message.direction === "FAMILY_TO_ELDER",
);
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="留言独立成页后,每条消息都能用编号直达。"
description="不论是老人留给家里的话,还是家属写给老人的问候,都集中在这里。点击任何一条卡片,都会进入带 URL 编号的详情页。"
caregiverToken={caregiver.sessionToken}
actions={<PwaControls />}
>
<section className="grid gap-4 md:grid-cols-3">
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
<p className="text-sm text-[var(--muted)]">全部留言</p>
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
{messages.length}
</p>
<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>
<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)]">
{unreadIncomingCount}
</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)]">
{unreadOutgoingCount}
</p>
</div>
</section>
{messages.length === 0 ? (
<section className="rounded-[32px] border border-dashed border-[var(--line)] bg-white/72 px-6 py-10 text-center shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
<h2 className="font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
还没有留言记录
</h2>
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
去设备页给老人留一句问候,或等待老人通过桥接工具给家里捎话;新记录会自动出现在这里,并带有可分享的消息编号。
</p>
<div className="mt-6 flex justify-center gap-3">
<Link
href="/devices"
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
>
去设备页
</Link>
<Link
href="/scan"
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
>
去扫码绑定
</Link>
</div>
</section>
) : (
<section className="grid gap-6 xl:grid-cols-2">
{[
{
title: "老人给家里的留言",
subtitle: "收到新话时也会从这里直达详情页。",
items: incomingMessages,
},
{
title: "家属给老人的问候",
subtitle: "写出去的每条问候也有独立 URL,可回看送达状态。",
items: outgoingMessages,
},
].map((section) => (
<div
key={section.title}
className="rounded-[32px] border border-white/75 bg-white/80 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]"
>
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
留言列表
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
{section.title}
</h2>
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">{section.subtitle}</p>
<div className="mt-5 space-y-3">
{section.items.length === 0 ? (
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
这一侧暂时还没有留言。
</p>
) : (
section.items.map((message) => (
<Link
key={message.id}
href={getMessageHref(message.publicId)}
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
>
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
<span className="rounded-full bg-white px-3 py-1 font-semibold text-[var(--ink)]">
#{message.publicId}
<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>
<span>{getMessageDirectionLabel(message.direction)}</span>
<span>{getDeviceName(message.elderDevice.displayName)}</span>
<span>{getImportanceLabel(message.importance)}</span>
<span>{getMessageStatusLabel(message)}</span>
</div>
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
{truncateText(message.content, 112)}
</p>
<p className="mt-3 text-xs text-[var(--muted)]">
创建于 {formatDateTime(message.createdAt)}
</p>
</Link>
))
)}
</div>
</div>
))}
)) : <div className="empty">这一侧暂时还没有留言。</div>}
</section>
)}
</PanelShell>
);
}
+2 -239
View File
@@ -1,239 +1,2 @@
import Link from "next/link";
import { PanelShell } from "@/components/panel-shell";
import { PwaControls } from "@/components/pwa-controls";
import { getCaregiverOverview } from "@/lib/caregiver-panel";
import {
formatDateTime,
getDeviceName,
getImportanceLabel,
getMessageHref,
truncateText,
} from "@/lib/panel-format";
import { requireCaregiverSession } from "@/lib/page-auth";
export const dynamic = "force-dynamic";
export default async function Home() {
const caregiver = await requireCaregiverSession("/");
const { dashboard, recentIncomingMessages, recentOutgoingMessages } =
await getCaregiverOverview(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 (
<PanelShell
currentPath="/"
title="首页只保留摘要,不再把所有操作挤在一处。"
description="设备绑定、留言管理、活动回看和扫码入口已经拆到独立路径。这里保留今天最值得先看的摘要,以及最近的新留言。"
caregiverToken={dashboard.caregiver.sessionToken}
actions={<PwaControls />}
>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
<p className="text-sm text-[var(--muted)]">已连接老人设备</p>
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
{dashboard.devices.length}
</p>
</div>
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
<p className="text-sm text-[var(--muted)]">老人给家里的未读留言</p>
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
{totalUnreadForCaregiver}
</p>
</div>
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
<p className="text-sm text-[var(--muted)]">待老人查看的家属留言</p>
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
{totalUnreadForElder}
</p>
</div>
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
<p className="text-sm text-[var(--muted)]">最近存档对话条数</p>
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
{totalConversations}
</p>
</div>
</section>
<section className="grid gap-4 lg:grid-cols-4">
{[
{
href: "/messages",
title: "留言中心",
text: "集中看所有老人留言和家属留言,每条消息都有 URL 编号可直接打开。",
tone: "bg-[rgba(53,84,141,0.12)]",
},
{
href: "/devices",
title: "设备与绑定",
text: "去管理设备、扫码绑定、查看某一台设备的专属详情页和留言入口。",
tone: "bg-[rgba(118,132,94,0.14)]",
},
{
href: "/activity",
title: "活动回看",
text: "把使用事件、工具调用和对话片段拆出来,回看时不再和留言混在一起。",
tone: "bg-[rgba(199,103,51,0.12)]",
},
{
href: "/scan",
title: "扫码绑定",
text: "拿起另一台手机时,直接走扫码页,不必再回首页寻找入口。",
tone: "bg-[rgba(36,27,20,0.08)]",
},
].map((item) => (
<Link
key={item.href}
href={item.href}
className="rounded-[28px] border border-white/80 bg-white/84 p-5 shadow-[0_20px_50px_rgba(115,76,42,0.10)] transition hover:-translate-y-0.5"
>
<div className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold text-[var(--ink)] ${item.tone}`}>
跳转路径
</div>
<h2 className="mt-4 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
{item.title}
</h2>
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">{item.text}</p>
<p className="mt-4 text-sm font-semibold text-[var(--copper)]">前往 {item.href}</p>
</Link>
))}
</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)]">
绑定完成后,留言中心会出现 URL 编号消息卡,活动页会出现陪伴记录,推送也能直达具体留言。
</p>
<div className="mt-6 flex justify-center gap-3">
<Link
href="/devices"
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
>
去设备页
</Link>
<Link
href="/scan"
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
>
直接扫码
</Link>
</div>
</section>
) : (
<section className="grid gap-6 xl:grid-cols-2">
<div className="rounded-[32px] border border-white/75 bg-white/80 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
新收到的老人留言
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
点一下就能按编号直达
</h2>
</div>
<Link
href="/messages"
className="inline-flex h-11 items-center justify-center rounded-full border border-[var(--line)] bg-white px-4 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
>
查看全部留言
</Link>
</div>
<div className="mt-5 space-y-3">
{recentIncomingMessages.length === 0 ? (
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
目前还没有新的老人留言。
</p>
) : (
recentIncomingMessages.map((message) => (
<Link
key={message.id}
href={getMessageHref(message.publicId)}
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
>
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
<span className="rounded-full bg-white px-3 py-1 font-semibold text-[var(--ink)]">
#{message.publicId}
</span>
<span>{getDeviceName(message.elderDevice.displayName)}</span>
<span>{getImportanceLabel(message.importance)}</span>
<span>{formatDateTime(message.createdAt)}</span>
</div>
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
{truncateText(message.content)}
</p>
</Link>
))
)}
</div>
</div>
<div className="rounded-[32px] border border-white/75 bg-white/80 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
最近发给老人的问候
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
发出后也能按编号回看
</h2>
</div>
<Link
href="/devices"
className="inline-flex h-11 items-center justify-center rounded-full border border-[var(--line)] bg-white px-4 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
>
去设备页留言
</Link>
</div>
<div className="mt-5 space-y-3">
{recentOutgoingMessages.length === 0 ? (
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
还没有新的家属留言,可以到设备页针对某一台设备写下问候。
</p>
) : (
recentOutgoingMessages.map((message) => (
<Link
key={message.id}
href={getMessageHref(message.publicId)}
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
>
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--muted)]">
<span className="rounded-full bg-white px-3 py-1 font-semibold text-[var(--ink)]">
#{message.publicId}
</span>
<span>{getDeviceName(message.elderDevice.displayName)}</span>
<span>{getImportanceLabel(message.importance)}</span>
<span>{formatDateTime(message.createdAt)}</span>
</div>
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
{truncateText(message.content)}
</p>
</Link>
))
)}
</div>
</div>
</section>
)}
</PanelShell>
);
}
import { redirect } from "next/navigation";
export default function Home() { redirect("/guard"); }
+15 -32
View File
@@ -1,40 +1,23 @@
import Link from "next/link";
import { PanelShell } from "@/components/panel-shell";
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 (
<main className="mx-auto min-h-screen max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
<section className="grid gap-6 lg:grid-cols-[0.82fr_1.18fr]">
<div className="rounded-[36px] border border-white/70 bg-white/78 p-6 shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
扫码绑定
</p>
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl leading-[1.15] text-[var(--ink)]">
对准老人手机里的家人连接二维码
</h1>
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
扫描成功后,系统会自动把老人的本机 UUID 绑定到当前家属账号。之后,您就能看见老人的留言、最近对话和使用记录。
</p>
<div className="mt-6 space-y-3 rounded-[28px] bg-[var(--paper-soft)] p-5 text-sm leading-7 text-[var(--muted)]">
<p>1. 让老人打开安卓端的家人连接页。</p>
<p>2. 把镜头对准屏幕上的二维码,停稳一两秒。</p>
<p>3. 绑定成功后会自动跳回控制台。</p>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/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>
</div>
<ScanClient />
<PanelShell currentPath="/scan" title="扫码绑定" caregiverLabel={getCaregiverDisplayName(caregiver)} backHref="/devices">
<div className="scan-panel"><ScanClient /></div>
<section className="card card-pad" style={{ color: "var(--muted)", fontSize: 12.5, lineHeight: 1.7 }}>
<p style={{ margin: 0 }}>1. 让长辈打开陪伴屏上的「家人连接」页面。</p>
<p style={{ margin: "4px 0 0" }}>2. 将镜头对准屏幕上的二维码,保持一两秒。</p>
<p style={{ margin: "4px 0 0" }}>3. 识别成功后自动完成绑定并返回设备页。</p>
</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>
);
}
+534 -590
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>
);
}
+186 -77
View File
@@ -2,10 +2,13 @@
import Link from "next/link";
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() {
const router = useRouter();
const { beginNavigationProgress } = useNavigationProgress();
const [rawCode, setRawCode] = useState("");
const [submitting, setSubmitting] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
@@ -14,84 +17,42 @@ export function BindDeviceForm() {
event.preventDefault();
if (!rawCode.trim()) {
setNotice("先把老人二维码里的设备码贴进来。\n");
setNotice("请先粘贴长辈手机上的设备码。\n");
return;
}
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(() => {
router.refresh();
beginNavigationProgress();
router.push(`/bind?deviceUuid=${encodeURIComponent(rawCode.trim())}`);
});
} catch (error) {
setNotice(
error instanceof Error ? error.message : "连接失败,请稍后再试。",
);
} finally {
setSubmitting(false);
}
}
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="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-xl">
<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}>
<div className="card card-pad">
<p style={{ margin: 0, fontSize: 13, fontWeight: 600 }}>用设备码添加</p>
<form className="button-row" style={{ marginTop: 9 }} onSubmit={handleSubmit}>
<input
value={rawCode}
onChange={(event) => setRawCode(event.target.value)}
placeholder="粘贴二维码链接,或者直接贴设备 UUID"
className="h-13 flex-1 rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
placeholder="粘贴长辈屏幕下方的设备码"
className="field"
style={{ height: 36, flex: 1, minWidth: 0 }}
/>
<button
type="submit"
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>
</form>
{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}
</p>
) : null}
@@ -115,7 +76,7 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
event.preventDefault();
if (!content.trim()) {
setNotice("先写下想对老人说的话。\n");
setNotice("请先写下您想说的话。\n");
setCreatedMessagePublicId(null);
return;
}
@@ -144,7 +105,7 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
throw new Error(payload?.error || "留言发送失败,请稍后再试。");
}
setNotice("留言已经写进家人的收件盒,老人下次打开或询问时就能看到。\n");
setNotice("留言已发出,长辈下次使用时就能看到。\n");
setCreatedMessagePublicId(
typeof payload?.publicId === "number" ? payload.publicId : null,
);
@@ -164,20 +125,14 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
}
return (
<form className="rounded-[28px] border border-white/70 bg-white/88 p-5" onSubmit={handleSubmit}>
<div className="flex items-center justify-between gap-3">
<div>
<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>
<form className="card card-pad" onSubmit={handleSubmit}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<h4 style={{ margin: 0, fontSize: 13, fontWeight: 600 }}>给长辈留言</h4>
<select
value={importance}
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="NORMAL">日常问候</option>
@@ -191,24 +146,22 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
onChange={(event) => setContent(event.target.value)}
rows={4}
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">
<p className="text-sm leading-7 text-[var(--muted)]">
这条话会进入老人的留言列表,也会被 function calling 工具读取到。
</p>
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", marginTop: 9 }}>
<button
type="submit"
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 ? "正在送达" : "写好并送出"}
</button>
</div>
{notice ? (
<div className="mt-3 space-y-2 text-sm leading-7 text-[var(--muted)]">
<div style={{ marginTop: 9, color: "var(--success)", fontSize: 12, lineHeight: 1.6 }}>
<p className="whitespace-pre-line">{notice}</p>
{createdMessagePublicId ? (
<Link
@@ -223,3 +176,159 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
</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 ? (
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
{notice}
</p>
) : null}
</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;
}
+61 -64
View File
@@ -1,97 +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: "/", label: "总览" },
{ href: "/messages", label: "留言" },
{ href: "/devices", label: "设备" },
{ href: "/activity", label: "活动" },
{ href: "/scan", label: "扫码" },
{ 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) {
if (href === "/") {
return currentPath === "/";
}
return currentPath === href || currentPath.startsWith(`${href}/`);
}
type PanelShellProps = {
currentPath: string;
title: string;
description: string;
caregiverToken: string;
description?: string;
caregiverLabel: string;
caregiverId?: string;
children: ReactNode;
actions?: ReactNode;
eyebrow?: string;
backHref?: string;
showTabs?: boolean;
unreadCount?: number;
};
export function PanelShell({
export async function PanelShell({
currentPath,
title,
description,
caregiverToken,
caregiverLabel,
caregiverId,
children,
actions,
eyebrow = "Digital Human Bridge",
backHref,
showTabs = !backHref,
unreadCount,
}: PanelShellProps) {
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-72 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.8),transparent_70%)]" />
<div className="mx-auto max-w-7xl space-y-6">
<header className="paper-panel relative overflow-hidden rounded-[36px] border border-white/70 px-6 py-7 shadow-[0_35px_90px_rgba(105,68,41,0.13)] sm:px-8 lg:px-10 lg:py-8">
<div className="absolute -right-12 top-[-50px] h-44 w-44 rounded-full bg-[rgba(53,84,141,0.14)] blur-2xl" />
<div className="absolute left-10 top-10 h-24 w-24 rounded-full border border-[rgba(118,132,94,0.18)]" />
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
<div className="max-w-4xl">
<div className="flex flex-wrap gap-3">
{navigationItems.map((item) => {
const active = isActivePath(currentPath, item.href);
const initial = caregiverLabel.trim().slice(-1) || "安";
const resolvedUnreadCount = unreadCount ?? (showTabs && caregiverId
? await getCaregiverUnreadIncomingCount(caregiverId)
: 0);
return (
<Link
key={item.href}
href={item.href}
className={`inline-flex h-11 items-center justify-center rounded-full px-4 text-sm font-semibold transition ${
active
? "bg-[var(--ink)] text-white shadow-[0_12px_30px_rgba(36,27,20,0.18)]"
: "border border-[var(--line)] bg-white/82 text-[var(--ink)] hover:bg-[var(--paper-soft)]"
}`}
>
{item.label}
<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>
);
})}
</div>
<p className="mt-6 text-sm font-semibold tracking-[0.24em] text-[var(--copper)] uppercase">
{eyebrow}
</p>
<h1 className="mt-4 max-w-3xl font-[family-name:var(--font-display)] text-4xl leading-[1.15] text-[var(--ink)] sm:text-5xl">
{title}
</h1>
<p className="mt-5 max-w-2xl text-base leading-8 text-[var(--muted)] sm:text-lg">
{description}
</p>
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
<span className="rounded-full border border-[var(--line)] bg-white/76 px-4 py-2">
当前家属账号:{caregiverToken.slice(0, 8).toUpperCase()}
</span>
<span className="rounded-full border border-[var(--line)] bg-white/76 px-4 py-2">
不同功能已拆到独立路径,消息支持单条直达
</span>
</div>
</div>
{actions ? <div className="w-full max-w-md xl:pt-2">{actions}</div> : null}
</div>
<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>
);
}
+121 -160
View File
@@ -1,81 +1,83 @@
"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;
}>;
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
};
type NotificationState =
| "checking"
| "idle"
| "pending"
| "enabled"
| "blocked"
| "unsupported";
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);
}
for (let index = 0; index < rawData.length; index += 1) outputArray[index] = rawData.charCodeAt(index);
return outputArray;
}
export function PwaControls() {
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 [installPromptEvent, setInstallPromptEvent] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
const [installing, setInstalling] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
const [notificationState, setNotificationState] =
useState<NotificationState>("checking");
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) {
return;
if (active) setInstallPromptEvent(event as BeforeInstallPromptEvent);
}
setInstallPromptEvent(event as BeforeInstallPromptEvent);
function handleAppInstalled() {
writeInstalledMarker();
if (active) setIsInstalled(true);
}
async function syncExistingSubscription(subscription: PushSubscription) {
await fetch("/api/push/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
subscription: subscription.toJSON(),
}),
});
}
async function bootstrap() {
const standalone =
window.matchMedia("(display-mode: standalone)").matches ||
Boolean((navigator as Navigator & { standalone?: boolean }).standalone);
setIsStandalone(standalone);
if (
!("serviceWorker" in navigator) ||
!("PushManager" in window) ||
typeof Notification === "undefined"
) {
async function bootstrapNotifications() {
if (!("serviceWorker" in navigator) || !("PushManager" in window) || typeof Notification === "undefined") {
setNotificationState("unsupported");
return;
}
@@ -83,87 +85,74 @@ export function PwaControls() {
try {
await navigator.serviceWorker.register("/sw.js");
const registration = await navigator.serviceWorker.ready;
const existingSubscription = await registration.pushManager.getSubscription();
const subscription = await registration.pushManager.getSubscription();
if (!active) return;
if (!active) {
return;
}
if (existingSubscription) {
if (subscription) {
if (vapidPublicKey) {
await syncExistingSubscription(existingSubscription);
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subscription: subscription.toJSON() }),
});
}
setNotificationState("enabled");
return;
} else {
setNotificationState(Notification.permission === "denied" ? "blocked" : "idle");
}
setNotificationState(
Notification.permission === "denied" ? "blocked" : "idle",
);
} catch {
if (active) {
setNotificationState("unsupported");
}
if (active) setNotificationState("unsupported");
}
}
syncInstallState();
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
void bootstrap();
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("当前环境还没配置推送密钥,先把面板安装到桌面即可。\n");
setNotice("当前还没有配置推送服务。");
return;
}
setNotificationState("pending");
setNotice(null);
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
setNotificationState(permission === "denied" ? "blocked" : "idle");
setNotice(
permission === "denied"
? "浏览器已经拒绝消息提醒,请在系统设置里重新打开通知权限。\n"
: "这次没有打开通知权限,稍后仍可再次开启。\n",
);
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(),
}),
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subscription: subscription.toJSON() }),
});
setNotificationState("enabled");
setNotice("新留言会直接推到这台设备上,点开即可直达对应留言。\n");
setNotice("已开启紧急提醒推送。");
} catch {
setNotificationState("idle");
setNotice("打开消息提醒失败,请稍后再试。\n");
setNotice("打开消息提醒失败,请稍后再试。");
}
}
@@ -171,117 +160,89 @@ export function PwaControls() {
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,
}),
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
await subscription.unsubscribe();
}
setNotificationState("idle");
setNotice("这台设备的消息提醒已关闭。\n");
setNotice("这台设备的紧急提醒已关闭。");
} catch {
setNotice("关闭消息提醒失败,请稍后再试。\n");
setNotice("关闭消息提醒失败,请稍后再试。");
}
}
async function installApp() {
if (isInstalled) {
setNotice("安智伴已经添加到这台设备的主屏幕。");
return;
}
if (!installPromptEvent) {
setNotice("如果浏览器没有弹出安装按钮,可以从菜单里选择“安装应用”或“添加到主屏幕”。\n");
setNotice("请从浏览器菜单中选择“安装应用”或“添加到主屏幕”。");
return;
}
setInstalling(true);
setNotice(null);
try {
await installPromptEvent.prompt();
const choice = await installPromptEvent.userChoice;
if (choice.outcome === "accepted") {
writeInstalledMarker();
setInstallPromptEvent(null);
setNotice("安装提示已经确认,完成后就能像应用一样直接打开。\n");
} else {
setNotice("这次先关闭了安装提示,之后仍可从浏览器菜单安装。\n");
setIsInstalled(true);
setNotice("已确认添加到主屏幕。");
}
} finally {
setInstalling(false);
}
}
const pushOn = notificationState === "enabled";
const toggleDisabled = notificationState === "checking" || notificationState === "pending" || notificationState === "unsupported";
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">
{isStandalone ? "当前已可从桌面直接打开" : "浏览器内访问中"}
</span>
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
{notificationState === "enabled"
? "消息提醒已开启"
: notificationState === "blocked"
? "通知权限已被拒绝"
: "消息提醒未开启"}
<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>
<div className="mt-5 flex flex-wrap gap-3">
<button
type="button"
onClick={() => {
void installApp();
}}
disabled={installing}
className="inline-flex h-12 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"
>
{installing ? "正在呼起安装" : "安装到桌面"}
<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>
{notificationState === "enabled" ? (
<button
type="button"
onClick={() => {
void disableNotifications();
}}
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)]"
>
关闭消息提醒
</button>
) : (
<button
type="button"
onClick={() => {
void enableNotifications();
}}
disabled={notificationState === "pending" || notificationState === "unsupported"}
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--olive)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--olive-deep)] disabled:cursor-not-allowed disabled:opacity-60"
>
{notificationState === "pending" ? "正在开启提醒" : "打开消息提醒"}
</button>
)}
</div>
{notice ? (
<p className="mt-4 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
{notice}
</p>
) : null}
{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,
} from "react";
import { useNavigationProgress } from "@/components/navigation-progress";
function extractDeviceUuid(rawValue: string) {
const trimmedValue = rawValue.trim();
@@ -31,6 +33,7 @@ function extractDeviceUuid(rawValue: string) {
export function ScanClient() {
const router = useRouter();
const { beginNavigationProgress } = useNavigationProgress();
const videoRef = useRef<HTMLVideoElement | null>(null);
const scannerRef = useRef<QrScanner | null>(null);
const [statusText, setStatusText] = useState("正在准备镜头……");
@@ -45,38 +48,14 @@ export function ScanClient() {
scannerRef.current.stop();
setErrorText(null);
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 || "绑定失败,请重新扫描一次。");
}
setStatusText("识别到了设备,正在打开绑定页面……");
startTransition(() => {
beginNavigationProgress();
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(() => {
@@ -91,7 +70,7 @@ export function ScanClient() {
const hasCamera = await QrScanner.hasCamera();
if (!hasCamera) {
setErrorText("当前设备没有可用摄像头,请改用系统扫码或手动粘贴设备码。");
setErrorText("当前设备没有可用摄像头,请改用手动粘贴设备码。");
setStatusText("无法打开镜头");
return;
}
@@ -114,7 +93,7 @@ export function ScanClient() {
await scanner.start();
if (!cancelled) {
setStatusText("请把老人的二维码放到取景框中央。");
setStatusText("请将长辈手机上的二维码放到取景框内。");
}
} catch (error) {
setErrorText(
@@ -136,8 +115,18 @@ export function ScanClient() {
}, []);
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="relative overflow-hidden rounded-[28px] border border-[var(--line)] bg-[var(--ink)]/95">
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
扫码画面
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
将二维码放入取景框
</h2>
<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
ref={videoRef}
muted
@@ -145,13 +134,12 @@ export function ScanClient() {
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-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 className="mt-4 space-y-2">
<p className="text-base font-semibold text-[var(--ink)]">{statusText}</p>
<p className="text-sm leading-7 text-[var(--muted)]">
扫到后会直接把这位老人绑定到当前家属账号,不需要额外输密码。
扫描成功后会自动连接长辈的设备,无需额外操作。
</p>
{errorText ? (
<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/**",
"build/**",
"next-env.d.ts",
// Design reference exported by the prototyping tool; not application source.
"安智伴家属端界面优化/**",
]),
]);
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

+123 -8
View File
@@ -11,6 +11,61 @@ const deviceSummarySelect = {
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);
@@ -36,6 +91,11 @@ export async function getCaregiverOverview(caregiverId: string) {
where: {
direction: MessageDirection.FAMILY_TO_ELDER,
caregiverId,
elderDevice: {
bindings: {
some: { caregiverId },
},
},
},
include: {
elderDevice: {
@@ -47,6 +107,15 @@ export async function getCaregiverOverview(caregiverId: string) {
}),
]);
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,
@@ -54,6 +123,20 @@ export async function getCaregiverOverview(caregiverId: string) {
};
}
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({
@@ -72,9 +155,11 @@ export async function getCaregiverMessages(caregiverId: string) {
orderBy: [{ createdAt: "desc" }, { publicId: "desc" }],
take: 120,
}),
getCaregiverUnreadIncomingCount(caregiverId),
prisma.familyMessage.count({
where: {
direction: MessageDirection.ELDER_TO_FAMILY,
direction: MessageDirection.FAMILY_TO_ELDER,
caregiverId,
elderDevice: {
bindings: {
some: { caregiverId },
@@ -83,15 +168,15 @@ export async function getCaregiverMessages(caregiverId: string) {
readAt: null,
},
}),
prisma.familyMessage.count({
where: {
direction: MessageDirection.FAMILY_TO_ELDER,
caregiverId,
readAt: null,
},
}),
]);
const deviceNameMap = await loadCaregiverDeviceNameMap(
caregiverId,
[...new Set(messages.map((message) => message.elderDeviceId))],
);
applyCaregiverDeviceNames(messages, deviceNameMap);
return {
messages,
unreadIncomingCount,
@@ -140,6 +225,9 @@ export async function getCaregiverMessageDetail(
});
}
const deviceNameMap = await loadCaregiverDeviceNameMap(caregiverId, [message.elderDeviceId]);
applyCaregiverDeviceNames([message], deviceNameMap);
return message;
}
@@ -215,6 +303,11 @@ export async function getCaregiverDeviceDetail(
return {
...binding,
elderDevice: {
...binding.elderDevice,
displayName:
binding.alias?.trim() || binding.elderDevice.displayName?.trim() || null,
},
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
familyUnreadCount,
elderUnreadCount,
@@ -278,9 +371,31 @@ export async function getCaregiverActivity(caregiverId: string) {
}),
]);
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;
}
}
+211
View File
@@ -46,6 +46,7 @@ type ToolCallInput = {
const PUBLIC_BASE_URL = (
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
).replace(/\/+$/, "");
const DEVICE_ALIAS_MAX_LENGTH = 24;
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;
@@ -90,6 +91,24 @@ export function extractDeviceUuid(rawValue: string) {
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) {
const deviceUuid = normalizeDeviceUuid(input.deviceUuid);
@@ -121,6 +140,8 @@ export async function bindDeviceToCaregiver(
const device = await ensureDeviceRegistration({ deviceUuid });
await migrateLegacyCaregiverDataForDevice(caregiverId, device.id);
await prisma.deviceBinding.upsert({
where: {
caregiverId_elderDeviceId: {
@@ -138,6 +159,104 @@ export async function bindDeviceToCaregiver(
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) {
const normalizedImportance = rawImportance?.trim().toUpperCase();
@@ -295,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) {
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
@@ -396,6 +585,21 @@ export async function recordToolCall(input: ToolCallInput) {
});
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 {
@@ -493,6 +697,13 @@ export async function getCaregiverDashboard(caregiverId: string) {
caregiver,
devices: caregiver.bindings.map((binding) => ({
...binding,
elderDevice: {
...binding.elderDevice,
displayName: resolveBindingDisplayName(
binding.alias,
binding.elderDevice.displayName,
),
},
familyUnreadCount: familyUnreadMap.get(binding.elderDeviceId) || 0,
elderUnreadCount: elderUnreadMap.get(binding.elderDeviceId) || 0,
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
+2 -2
View File
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import {
buildCaregiverSessionBootstrapPath,
buildCaregiverLoginPath,
getCaregiverSession,
} from "@/lib/session";
@@ -9,7 +9,7 @@ export async function requireCaregiverSession(redirectTo: string) {
const caregiver = await getCaregiverSession();
if (!caregiver) {
redirect(buildCaregiverSessionBootstrapPath(redirectTo));
redirect(buildCaregiverLoginPath(redirectTo));
}
return caregiver;
+8 -5
View File
@@ -39,7 +39,7 @@ export function getUsageLabel(eventType: UsageEventType | string) {
case UsageEventType.APP_OPEN:
return "打开了老人端应用";
case UsageEventType.SETTINGS_OPENED:
return "打开了家人连接页";
return "查看了设置页面";
case UsageEventType.AI_SESSION_STARTED:
return "开始了一次陪伴对话";
case UsageEventType.AI_SESSION_ENDED:
@@ -49,7 +49,7 @@ export function getUsageLabel(eventType: UsageEventType | string) {
case UsageEventType.CAMERA_DISABLED:
return "关闭了镜头模式";
case UsageEventType.TOOL_CALLED:
return "模型调用了一次桥接工具";
return "使用了一项智能服务";
default:
return eventType;
}
@@ -60,7 +60,7 @@ export function getConversationRoleLabel(role: ConversationRole | string) {
case ConversationRole.USER:
return "老人说";
case ConversationRole.TOOL:
return "工具结果";
return "智能助理";
default:
return "数字人回复";
}
@@ -109,6 +109,9 @@ export function getMessageHref(publicId: number) {
return `/messages/${publicId}`;
}
export function getDeviceName(displayName?: string | null) {
return displayName?.trim() || "未命名陪伴设备";
export function getDeviceName(
primaryName?: string | null,
fallbackName?: string | null,
) {
return primaryName?.trim() || fallbackName?.trim() || "长辈的设备";
}
+275 -25
View File
@@ -13,6 +13,37 @@ type SerializablePushSubscription = {
};
};
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(/\/+$/, "");
@@ -119,6 +150,154 @@ export async function removePushSubscription(input: {
});
}
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;
@@ -154,6 +333,25 @@ export async function sendIncomingMessagePush(publicId: number) {
},
},
},
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) {
@@ -162,7 +360,13 @@ export async function sendIncomingMessagePush(publicId: number) {
ensureVapidDetails();
const deviceName = getDeviceName(message.elderDevice.displayName);
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),
@@ -172,37 +376,83 @@ export async function sendIncomingMessagePush(publicId: number) {
tag: `message-${message.publicId}`,
});
await Promise.allSettled(
subscriptions.map(async (subscription) => {
try {
await webpush.sendNotification(
{
await sendPushNotification({
subscription: {
endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime?.getTime() || null,
keys: {
expirationTime: subscription.expirationTime,
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
payload,
{
TTL: 60 * 30,
urgency: "high",
topic: `msg-${message.publicId}`,
},
);
} catch (error) {
const statusCode =
error && typeof error === "object" && "statusCode" in error
? Number((error as { statusCode?: number }).statusCode)
: 0;
if (statusCode === 404 || statusCode === 410) {
await prisma.pushSubscription.deleteMany({
where: { endpoint: subscription.endpoint },
});
}
}
}),
);
}
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 { randomUUID } from "node:crypto";
@@ -6,6 +7,8 @@ import { prisma } from "@/lib/prisma";
export const CAREGIVER_SESSION_COOKIE = "dh-caregiver-session";
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() {
return randomUUID().replaceAll("-", "");
@@ -29,44 +32,170 @@ function normalizeRedirectPath(redirectTo?: string) {
return redirectTo;
}
async function upsertCaregiverByToken(sessionToken: string) {
return prisma.caregiverAccount.upsert({
where: { sessionToken },
update: {},
create: { sessionToken },
});
function normalizeCaregiverUsername(rawValue: string) {
return rawValue.trim().toLowerCase();
}
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);
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() {
const sessionToken = (await cookies()).get(CAREGIVER_SESSION_COOKIE)?.value;
const sessionToken = await getCurrentSessionToken();
if (!sessionToken) {
return null;
}
return upsertCaregiverByToken(sessionToken);
}
const session = await prisma.caregiverSession.findUnique({
where: { sessionToken },
include: {
caregiver: true,
},
});
export async function getOrCreateCaregiverSession() {
const cookieStore = await cookies();
let sessionToken = cookieStore.get(CAREGIVER_SESSION_COOKIE)?.value;
if (!sessionToken) {
sessionToken = createSessionToken();
cookieStore.set(CAREGIVER_SESSION_COOKIE, sessionToken, getCookieOptions());
if (!session) {
return null;
}
return upsertCaregiverByToken(sessionToken);
return session.caregiver;
}
export function createCaregiverSessionCookieValue() {
return {
sessionToken: createSessionToken(),
options: getCookieOptions(),
};
export async function registerCaregiverAccount(input: {
username: string;
password: string;
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);
}
+3
View File
@@ -1,6 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
turbopack: {
root: process.cwd(),
},
async headers() {
return [
{
+6308
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -12,12 +12,15 @@
"dependencies": {
"@prisma/adapter-pg": "^7.7.0",
"@prisma/client": "^7.7.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"next": "16.2.4",
"lucide-react": "^1.8.0",
"next": "^16.2.10",
"pg": "^8.20.0",
"qr-scanner": "^1.4.2",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.8.1",
"sharp": "^0.34.5",
"web-push": "^3.6.7"
},
@@ -33,6 +36,10 @@
"tailwindcss": "^4",
"typescript": "^5"
},
"optionalDependencies": {
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
"lightningcss-darwin-arm64": "1.32.0"
},
"ignoreScripts": [
"sharp",
"unrs-resolver"
@@ -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;
+354 -1
View File
@@ -45,15 +45,116 @@ enum ToolCallStatus {
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 {
id String @id @default(cuid())
sessionToken String @unique
username String? @unique
passwordHash String?
nickname String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions CaregiverSession[]
bindings DeviceBinding[]
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 {
@@ -69,12 +170,264 @@ model ElderDevice {
conversationTurns ConversationTurn[]
usageEvents UsageEvent[]
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 {
id String @id @default(cuid())
caregiverId String
elderDeviceId String
alias String?
createdAt DateTime @default(now())
caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade)
elderDevice ElderDevice @relation(fields: [elderDeviceId], references: [id], onDelete: Cascade)
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+3 -3
View File
@@ -1,10 +1,10 @@
self.addEventListener("push", (event) => {
let data = {
title: "家人连线台",
title: "安智伴 家人连线台",
body: "收到一条新的家人留言。",
url: "/",
icon: "/pwa/icon-192x192.png",
badge: "/pwa/badge-96x96.png",
icon: "/pwa/icon-192x192.webp",
badge: "/pwa/badge-96x96.webp",
tag: "digital-human-message",
};
+15 -12
View File
@@ -7,7 +7,7 @@ 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, "icon.png");
const sourceIconPath = path.join(projectRoot, "app", "icon.png");
const pwaOutputDir = path.join(projectRoot, "public", "pwa");
const androidResArg = process.argv.find((arg) =>
@@ -25,16 +25,19 @@ function createCircleMask(size) {
`);
}
async function writePng(size, outputPath) {
await sharp(sourceIconPath).resize(size, size).png().toFile(outputPath);
async function writeWebp(size, outputPath) {
await sharp(sourceIconPath)
.resize(size, size)
.webp({ quality: 88, effort: 6 })
.toFile(outputPath);
}
async function writeMaskablePng(size, outputPath) {
async function writeMaskableWebp(size, outputPath) {
const foreground = await sharp(sourceIconPath)
.resize(Math.round(size * 0.82), Math.round(size * 0.82), {
fit: "contain",
})
.png()
.webp({ quality: 88, effort: 6 })
.toBuffer();
await sharp({
@@ -42,7 +45,7 @@ async function writeMaskablePng(size, outputPath) {
width: size,
height: size,
channels: 4,
background: "#35548d",
background: "#FBFAF6",
},
})
.composite([
@@ -51,7 +54,7 @@ async function writeMaskablePng(size, outputPath) {
gravity: "center",
},
])
.png()
.webp({ quality: 88, effort: 6 })
.toFile(outputPath);
}
@@ -92,11 +95,11 @@ async function writeAndroidIcons(resDir) {
await mkdir(pwaOutputDir, { recursive: true });
await Promise.all([
writePng(192, path.join(pwaOutputDir, "icon-192x192.png")),
writePng(512, path.join(pwaOutputDir, "icon-512x512.png")),
writePng(180, path.join(pwaOutputDir, "apple-touch-icon.png")),
writePng(96, path.join(pwaOutputDir, "badge-96x96.png")),
writeMaskablePng(512, path.join(pwaOutputDir, "icon-maskable-512x512.png")),
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) {