Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbfc055c21 | ||
|
|
b68bfc6287 | ||
|
|
747dcd359a | ||
|
|
b7579c5947 | ||
|
|
386a28709f | ||
|
|
4dde861c99 | ||
|
|
23f8d42818 | ||
|
|
be1b44190c | ||
|
|
5cbb7217f0 | ||
|
|
70d42e6792 | ||
|
|
71760cbb1f | ||
|
|
3134aab4dc | ||
|
|
6a65d956a8 | ||
|
|
2cf1fae21d | ||
|
|
a255b23be3 | ||
|
|
3469f53d79 | ||
|
|
4e68054984 | ||
|
|
f2c56605ac | ||
|
|
dee056a76a | ||
|
|
d3b03cecf2 | ||
|
|
032e207888 |
@@ -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`。
|
||||
- 状态不能只依赖颜色,在线、未读、重要均同时提供文字或数量。
|
||||
- 动效仅用于在线呼吸点、导航进度和必要反馈,并尊重系统减少动态设置。
|
||||
@@ -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/) |
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/device/agenda/route";
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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,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),
|
||||
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 31 KiB |
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"); }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)]">
|
||||
|
||||
@@ -12,6 +12,8 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// Design reference exported by the prototyping tool; not application source.
|
||||
"安智伴家属端界面优化/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) } });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() || "长辈的设备";
|
||||
}
|
||||
@@ -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}` });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
root: process.cwd(),
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 592 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||