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 { PanelShell } from "@/components/panel-shell";
|
||||||
import { getCaregiverActivity } from "@/lib/caregiver-panel";
|
import { getCaregiverActivity } from "@/lib/caregiver-panel";
|
||||||
import {
|
import { formatDateTime, getConversationRoleLabel, getDeviceName, getUsageLabel, truncateText } from "@/lib/panel-format";
|
||||||
formatDateTime,
|
|
||||||
getConversationRoleLabel,
|
|
||||||
getDeviceName,
|
|
||||||
getUsageLabel,
|
|
||||||
truncateText,
|
|
||||||
} from "@/lib/panel-format";
|
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
|
import { getCaregiverDisplayName } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function ActivityPage() {
|
export default async function ActivityPage({ searchParams }: PageProps<"/activity">) {
|
||||||
const caregiver = await requireCaregiverSession("/activity");
|
const caregiver = await requireCaregiverSession("/activity");
|
||||||
const { usageEvents, conversationTurns, toolCalls } = await getCaregiverActivity(
|
const { usageEvents, conversationTurns, toolCalls } = await getCaregiverActivity(caregiver.id);
|
||||||
caregiver.id,
|
const rawTab = (await searchParams).tab;
|
||||||
);
|
const tab = rawTab === "chat" || rawTab === "tool" ? rawTab : "usage";
|
||||||
|
|
||||||
|
const 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 (
|
return (
|
||||||
<PanelShell
|
<PanelShell currentPath="/activity" title="陪伴动态" caregiverLabel={getCaregiverDisplayName(caregiver)} backHref="/">
|
||||||
currentPath="/activity"
|
<div className="segment">
|
||||||
title="活动记录独立成页,回看陪伴过程更清楚。"
|
<Link href="/activity?tab=usage" className={tab === "usage" ? "active" : ""}>使用</Link>
|
||||||
description="这里专门收下老人端的使用事件、最近对话片段和工具调用,不再和留言流混排。"
|
<Link href="/activity?tab=chat" className={tab === "chat" ? "active" : ""}>对话</Link>
|
||||||
caregiverToken={caregiver.sessionToken}
|
<Link href="/activity?tab=tool" className={tab === "tool" ? "active" : ""}>智能服务</Link>
|
||||||
>
|
</div>
|
||||||
{usageEvents.length === 0 && conversationTurns.length === 0 && toolCalls.length === 0 ? (
|
<section className="card">
|
||||||
<section className="rounded-[32px] border border-dashed border-[var(--line)] bg-white/72 px-6 py-10 text-center shadow-[0_20px_50px_rgba(115,76,42,0.10)]">
|
{rows.length ? rows.map((row) => (
|
||||||
<h2 className="font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<article key={row.id} className="timeline-item">
|
||||||
还没有活动记录
|
<div className="timeline-top" style={{ color: tab === "tool" ? "var(--accent)" : undefined }}>{row.top}<span className="timeline-time">{row.time}</span></div>
|
||||||
</h2>
|
<p className="timeline-body">{row.body}</p>
|
||||||
<p className="mx-auto mt-4 max-w-2xl text-base leading-8 text-[var(--muted)]">
|
</article>
|
||||||
等老人端开始对话、切换镜头或触发桥接工具后,这里会自动累积活动时间线。
|
)) : <div className="empty">这个分类暂时还没有动态。</div>}
|
||||||
</p>
|
</section>
|
||||||
</section>
|
<p className="muted-note">「智能服务」记录数字人助理自主调用的工具,包括照护知识查询与双向留言等。</p>
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</PanelShell>
|
</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 { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { recordConversationTurns } from "@/lib/monitor-data";
|
import { recordConversationTurns } from "@/lib/monitor-data";
|
||||||
|
import { requireDeviceAuth } from "@/lib/device-auth";
|
||||||
|
|
||||||
function toConversationRole(rawRole: unknown) {
|
function toConversationRole(rawRole: unknown) {
|
||||||
switch (rawRole) {
|
switch (rawRole) {
|
||||||
@@ -55,6 +56,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await requireDeviceAuth(request, body.deviceUuid);
|
||||||
const result = await recordConversationTurns({
|
const result = await recordConversationTurns({
|
||||||
deviceUuid: body.deviceUuid,
|
deviceUuid: body.deviceUuid,
|
||||||
sessionId: typeof body.sessionId === "string" ? body.sessionId : undefined,
|
sessionId: typeof body.sessionId === "string" ? body.sessionId : undefined,
|
||||||
|
|||||||
@@ -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 { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { markFamilyMessagesAsRead } from "@/lib/monitor-data";
|
import { markFamilyMessagesAsRead } from "@/lib/monitor-data";
|
||||||
|
import { requireDeviceAuth } from "@/lib/device-auth";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const body = (await request.json().catch(() => null)) as
|
const body = (await request.json().catch(() => null)) as
|
||||||
@@ -24,6 +25,7 @@ export async function POST(request: Request) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await requireDeviceAuth(request, body.deviceUuid);
|
||||||
await markFamilyMessagesAsRead(body.deviceUuid, messageIds);
|
await markFamilyMessagesAsRead(body.deviceUuid, messageIds);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
import { listFamilyMessagesForDevice } from "@/lib/monitor-data";
|
import { listFamilyMessagesForDevice } from "@/lib/monitor-data";
|
||||||
|
import { requireDeviceAuth } from "@/lib/device-auth";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const rawDeviceValue =
|
const rawDeviceValue =
|
||||||
@@ -20,6 +21,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await requireDeviceAuth(request, rawDeviceValue);
|
||||||
const data = await listFamilyMessagesForDevice(rawDeviceValue, take);
|
const data = await listFamilyMessagesForDevice(rawDeviceValue, take);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { createBindUrl, ensureDeviceRegistration } from "@/lib/monitor-data";
|
import { createBindUrl, ensureDeviceRegistration } from "@/lib/monitor-data";
|
||||||
|
import { createDeviceToken, hashDeviceToken } from "@/lib/device-auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const body = (await request.json().catch(() => null)) as
|
const body = (await request.json().catch(() => null)) as
|
||||||
@@ -8,6 +10,7 @@ export async function POST(request: Request) {
|
|||||||
deviceUuid?: string;
|
deviceUuid?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
appVersion?: string;
|
appVersion?: string;
|
||||||
|
deviceToken?: string;
|
||||||
}
|
}
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
@@ -26,10 +29,20 @@ export async function POST(request: Request) {
|
|||||||
appVersion:
|
appVersion:
|
||||||
typeof body.appVersion === "string" ? body.appVersion : undefined,
|
typeof body.appVersion === "string" ? body.appVersion : undefined,
|
||||||
});
|
});
|
||||||
|
const suppliedToken = typeof body.deviceToken === "string" ? body.deviceToken.trim() : "";
|
||||||
|
const tokenMatches = suppliedToken && device.deviceTokenHash === hashDeviceToken(suppliedToken);
|
||||||
|
if (device.deviceTokenHash && !tokenMatches) {
|
||||||
|
return NextResponse.json({ error: "设备认证失败,不能覆盖已注册设备。" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const deviceToken = tokenMatches ? suppliedToken : createDeviceToken();
|
||||||
|
if (!device.deviceTokenHash) {
|
||||||
|
await prisma.elderDevice.update({ where: { id: device.id }, data: { deviceTokenHash: hashDeviceToken(deviceToken) } });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
deviceUuid: device.deviceUuid,
|
deviceUuid: device.deviceUuid,
|
||||||
bindUrl: createBindUrl(device.deviceUuid),
|
bindUrl: createBindUrl(device.deviceUuid),
|
||||||
|
deviceToken,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ToolCallStatus } from "@prisma/client";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { recordToolCall } from "@/lib/monitor-data";
|
import { recordToolCall } from "@/lib/monitor-data";
|
||||||
|
import { requireDeviceAuth } from "@/lib/device-auth";
|
||||||
|
|
||||||
function toToolCallStatus(rawStatus: unknown) {
|
function toToolCallStatus(rawStatus: unknown) {
|
||||||
return rawStatus === ToolCallStatus.FAILURE
|
return rawStatus === ToolCallStatus.FAILURE
|
||||||
@@ -49,6 +50,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await requireDeviceAuth(request, body.deviceUuid);
|
||||||
const log = await recordToolCall({
|
const log = await recordToolCall({
|
||||||
deviceUuid: body.deviceUuid,
|
deviceUuid: body.deviceUuid,
|
||||||
toolName: body.toolName,
|
toolName: body.toolName,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { UsageEventType } from "@prisma/client";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { recordUsageEvent } from "@/lib/monitor-data";
|
import { recordUsageEvent } from "@/lib/monitor-data";
|
||||||
|
import { requireDeviceAuth } from "@/lib/device-auth";
|
||||||
|
|
||||||
function toUsageEventType(rawType: unknown) {
|
function toUsageEventType(rawType: unknown) {
|
||||||
switch (rawType) {
|
switch (rawType) {
|
||||||
@@ -46,6 +47,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await requireDeviceAuth(request, body.deviceUuid);
|
||||||
await recordUsageEvent({
|
await recordUsageEvent({
|
||||||
deviceUuid: body.deviceUuid,
|
deviceUuid: body.deviceUuid,
|
||||||
eventType: toUsageEventType(body.eventType),
|
eventType: toUsageEventType(body.eventType),
|
||||||
|
|||||||
@@ -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 { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { bindDeviceToCaregiver, createBindUrl } from "@/lib/monitor-data";
|
import { bindDeviceToCaregiver, createBindUrl } from "@/lib/monitor-data";
|
||||||
import { getOrCreateCaregiverSession } from "@/lib/session";
|
import { getCaregiverSession } from "@/lib/session";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const body = (await request.json().catch(() => null)) as
|
const body = (await request.json().catch(() => null)) as
|
||||||
@@ -18,7 +18,15 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const caregiver = await getOrCreateCaregiverSession();
|
const caregiver = await getCaregiverSession();
|
||||||
|
|
||||||
|
if (!caregiver) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "请先登录账号后再绑定设备。" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const device = await bindDeviceToCaregiver(caregiver.id, body.deviceUuid);
|
const device = await bindDeviceToCaregiver(caregiver.id, body.deviceUuid);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -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 { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { createFamilyMessageFromCaregiver } from "@/lib/monitor-data";
|
import { createFamilyMessageFromCaregiver } from "@/lib/monitor-data";
|
||||||
import { getOrCreateCaregiverSession } from "@/lib/session";
|
import { getCaregiverSession } from "@/lib/session";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const body = (await request.json().catch(() => null)) as
|
const body = (await request.json().catch(() => null)) as
|
||||||
@@ -27,7 +27,15 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const caregiver = await getOrCreateCaregiverSession();
|
const caregiver = await getCaregiverSession();
|
||||||
|
|
||||||
|
if (!caregiver) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "请先登录账号后再发送留言。" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const message = await createFamilyMessageFromCaregiver({
|
const message = await createFamilyMessageFromCaregiver({
|
||||||
caregiverId: caregiver.id,
|
caregiverId: caregiver.id,
|
||||||
rawDeviceValue: body.deviceUuid,
|
rawDeviceValue: body.deviceUuid,
|
||||||
|
|||||||
@@ -1,49 +1,22 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CAREGIVER_SESSION_COOKIE,
|
buildCaregiverLoginPath,
|
||||||
createCaregiverSessionCookieValue,
|
|
||||||
getCaregiverSession,
|
getCaregiverSession,
|
||||||
|
sanitizeCaregiverRedirectPath,
|
||||||
} from "@/lib/session";
|
} from "@/lib/session";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
function normalizeRedirectPath(redirectTo?: string | null) {
|
|
||||||
if (!redirectTo || !redirectTo.startsWith("/") || redirectTo.startsWith("//")) {
|
|
||||||
return "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirectTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const redirectTo = normalizeRedirectPath(
|
const redirectTo = sanitizeCaregiverRedirectPath(
|
||||||
request.nextUrl.searchParams.get("redirectTo"),
|
request.nextUrl.searchParams.get("redirectTo"),
|
||||||
);
|
);
|
||||||
const existingCaregiver = await getCaregiverSession();
|
const caregiver = await getCaregiverSession();
|
||||||
const response = new NextResponse(null, {
|
const location = caregiver ? redirectTo : buildCaregiverLoginPath(redirectTo);
|
||||||
|
|
||||||
|
return new NextResponse(null, {
|
||||||
status: 307,
|
status: 307,
|
||||||
headers: {
|
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 { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { getOrCreateCaregiverSession } from "@/lib/session";
|
import { getCaregiverSession } from "@/lib/session";
|
||||||
import { savePushSubscription } from "@/lib/push";
|
import { savePushSubscription } from "@/lib/push";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -50,7 +50,15 @@ export async function POST(request: Request) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const caregiver = await getOrCreateCaregiverSession();
|
const caregiver = await getCaregiverSession();
|
||||||
|
|
||||||
|
if (!caregiver) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "请先登录账号后再开启消息提醒。" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await savePushSubscription({
|
await savePushSubscription({
|
||||||
caregiverId: caregiver.id,
|
caregiverId: caregiver.id,
|
||||||
subscription,
|
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 { bindDeviceToCaregiver } from "@/lib/monitor-data";
|
||||||
import {
|
import {
|
||||||
buildCaregiverSessionBootstrapPath,
|
buildCaregiverLoginPath,
|
||||||
getCaregiverSession,
|
getCaregiverSession,
|
||||||
} from "@/lib/session";
|
} from "@/lib/session";
|
||||||
|
|
||||||
@@ -27,13 +27,13 @@ export default async function BindPage({ searchParams }: BindPageProps) {
|
|||||||
<main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6">
|
<main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6">
|
||||||
<section className="w-full rounded-[36px] border border-white/70 bg-white/82 p-8 text-center shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
|
<section className="w-full rounded-[36px] border border-white/70 bg-white/82 p-8 text-center shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
连接家人设备
|
连接设备
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
还没有收到设备码
|
还没有收到设备信息
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
|
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
|
||||||
请让老人打开家人连接页,把二维码给您扫一下,或者把设备码复制过来。
|
请让长辈打开手机上的「家人连接」页面,把二维码给您扫一下,或将设备码复制过来。
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex justify-center gap-3">
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
@@ -46,7 +46,7 @@ export default async function BindPage({ searchParams }: BindPageProps) {
|
|||||||
href="/devices"
|
href="/devices"
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-white"
|
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-white"
|
||||||
>
|
>
|
||||||
回到设备页
|
返回设备页
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -58,29 +58,70 @@ export default async function BindPage({ searchParams }: BindPageProps) {
|
|||||||
|
|
||||||
if (!caregiver) {
|
if (!caregiver) {
|
||||||
redirect(
|
redirect(
|
||||||
buildCaregiverSessionBootstrapPath(
|
buildCaregiverLoginPath(
|
||||||
`/bind?deviceUuid=${encodeURIComponent(rawDeviceValue)}`,
|
`/bind?deviceUuid=${encodeURIComponent(rawDeviceValue)}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const device = await bindDeviceToCaregiver(caregiver.id, rawDeviceValue);
|
let device: Awaited<ReturnType<typeof bindDeviceToCaregiver>> | null = null;
|
||||||
|
let bindError: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
device = await bindDeviceToCaregiver(caregiver.id, rawDeviceValue);
|
||||||
|
} catch (error) {
|
||||||
|
bindError =
|
||||||
|
error instanceof Error ? error.message : "绑定失败,请稍后再试。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6">
|
||||||
|
<section className="w-full rounded-[36px] border border-white/70 bg-white/84 p-8 shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
|
||||||
|
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
|
暂时没连上
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
|
这次还没能完成设备连接
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
|
||||||
|
{bindError || "请重新扫码,或请长辈重新打开设备连接页面后再试一次。"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
|
<Link
|
||||||
|
href="/scan"
|
||||||
|
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
||||||
|
>
|
||||||
|
重新扫码
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/devices"
|
||||||
|
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
||||||
|
>
|
||||||
|
返回设备页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6">
|
<main className="mx-auto flex min-h-screen max-w-3xl items-center px-4 py-8 sm:px-6">
|
||||||
<section className="w-full rounded-[36px] border border-white/70 bg-white/84 p-8 shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
|
<section className="w-full rounded-[36px] border border-white/70 bg-white/84 p-8 shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
绑定完成
|
连接成功
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
||||||
这位老人已经连到当前家属账号
|
已成功连接长辈的设备
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
|
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
|
||||||
现在您可以在控制台查看老人给家里的留言、最近陪伴记录,也能马上给老人写下一句问候。
|
现在可以查看长辈的留言、了解使用动态,也可以随时给长辈写一句问候。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 rounded-[28px] bg-[var(--paper-soft)] p-5">
|
<div className="mt-6 rounded-[28px] bg-[var(--paper-soft)] p-5">
|
||||||
<p className="text-sm text-[var(--muted)]">设备 UUID</p>
|
<p className="text-sm text-[var(--muted)]">设备标识</p>
|
||||||
<p className="mt-2 break-all text-lg font-semibold text-[var(--ink)]">
|
<p className="mt-2 break-all text-lg font-semibold text-[var(--ink)]">
|
||||||
{device.deviceUuid}
|
{device.deviceUuid}
|
||||||
</p>
|
</p>
|
||||||
@@ -91,13 +132,13 @@ export default async function BindPage({ searchParams }: BindPageProps) {
|
|||||||
href={`/devices/${device.deviceUuid}`}
|
href={`/devices/${device.deviceUuid}`}
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
||||||
>
|
>
|
||||||
打开这台设备页
|
查看设备详情
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/devices"
|
href="/devices"
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
||||||
>
|
>
|
||||||
查看全部设备
|
返回设备列表
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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 Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
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 { PanelShell } from "@/components/panel-shell";
|
||||||
import { getCaregiverDeviceDetail } from "@/lib/caregiver-panel";
|
import { getCaregiverDeviceDetail } from "@/lib/caregiver-panel";
|
||||||
import {
|
import { formatDateTime, getConversationRoleLabel, getDeviceName, getMessageHref, getUsageLabel, truncateText } from "@/lib/panel-format";
|
||||||
formatDateTime,
|
|
||||||
getConversationRoleLabel,
|
|
||||||
getDeviceName,
|
|
||||||
getImportanceLabel,
|
|
||||||
getMessageHref,
|
|
||||||
getUsageLabel,
|
|
||||||
truncateText,
|
|
||||||
} from "@/lib/panel-format";
|
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
|
import { getCaregiverDisplayName } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type DeviceDetailPageProps = {
|
export default async function DeviceDetailPage({ params, searchParams }: PageProps<"/devices/[deviceUuid]">) {
|
||||||
params: Promise<{ deviceUuid: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function DeviceDetailPage({ params }: DeviceDetailPageProps) {
|
|
||||||
const { deviceUuid } = await params;
|
const { deviceUuid } = await params;
|
||||||
const caregiver = await requireCaregiverSession(`/devices/${deviceUuid}`);
|
const caregiver = await requireCaregiverSession(`/devices/${deviceUuid}`);
|
||||||
const binding = await getCaregiverDeviceDetail(caregiver.id, deviceUuid);
|
const binding = await getCaregiverDeviceDetail(caregiver.id, deviceUuid);
|
||||||
|
if (!binding) notFound();
|
||||||
if (!binding) {
|
const rawTab = (await searchParams).tab;
|
||||||
notFound();
|
const tab = rawTab === "chat" || rawTab === "usage" ? rawTab : "msg";
|
||||||
}
|
const messages = binding.elderDevice.messages;
|
||||||
|
|
||||||
const elderMessages = binding.elderDevice.messages.filter(
|
|
||||||
(message) => message.direction === "ELDER_TO_FAMILY",
|
|
||||||
);
|
|
||||||
const familyMessages = binding.elderDevice.messages.filter(
|
|
||||||
(message) => message.direction === "FAMILY_TO_ELDER",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell currentPath="/devices" title={getDeviceName(binding.alias, binding.elderDevice.displayName)} caregiverLabel={getCaregiverDisplayName(caregiver)} backHref="/devices"
|
||||||
currentPath="/devices"
|
actions={<span className="row-meta" style={{ margin: 0 }}><span className="dot online" style={{ margin: 0 }} />{formatDateTime(binding.elderDevice.lastSeenAt)}</span>}>
|
||||||
title={getDeviceName(binding.elderDevice.displayName)}
|
<section className="stats" style={{ gridTemplateColumns: "repeat(3,1fr)" }}>
|
||||||
description="这是一台设备的专属页面。给老人写问候、查看这台设备最近的留言、活动和对话,都在这里完成。"
|
<div className="stat accent"><strong>{binding.elderUnreadCount}</strong><span>新留言</span></div>
|
||||||
caregiverToken={caregiver.sessionToken}
|
<div className="stat"><strong>{binding.familyUnreadCount}</strong><span>待长辈听</span></div>
|
||||||
>
|
<div className="stat"><strong>{binding.elderDevice._count.conversationTurns}</strong><span>陪伴对话</span></div>
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">老人新留言</p>
|
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{binding.elderUnreadCount}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">待老人查看</p>
|
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
|
||||||
{binding.familyUnreadCount}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">最近在线</p>
|
|
||||||
<p className="mt-3 text-lg font-semibold text-[var(--ink)]">
|
|
||||||
{formatDateTime(binding.elderDevice.lastSeenAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
|
||||||
<p className="text-sm text-[var(--muted)]">绑定链接</p>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-[var(--ink)]">
|
|
||||||
{truncateText(binding.bindUrl, 52)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
<SendMessageForm deviceUuid={binding.elderDevice.deviceUuid} />
|
||||||
<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="segment">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<Link href={`/devices/${deviceUuid}?tab=msg`} className={tab === "msg" ? "active" : ""}>留言</Link>
|
||||||
<div>
|
<Link href={`/devices/${deviceUuid}?tab=chat`} className={tab === "chat" ? "active" : ""}>对话</Link>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<Link href={`/devices/${deviceUuid}?tab=usage`} className={tab === "usage" ? "active" : ""}>使用</Link>
|
||||||
老人给家里的话
|
</div>
|
||||||
</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">
|
<section className="card">
|
||||||
{elderMessages.length === 0 ? (
|
{tab === "msg" ? (messages.length ? messages.map((message) => (
|
||||||
<p className="rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 text-sm leading-7 text-[var(--muted)]">
|
<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>
|
<p className="timeline-body">{truncateText(message.content, 126)}</p>
|
||||||
) : (
|
</Link>
|
||||||
elderMessages.map((message) => (
|
)) : <div className="empty">还没有留言。</div>) : tab === "chat" ? (binding.elderDevice.conversationTurns.length ? binding.elderDevice.conversationTurns.map((turn) => (
|
||||||
<Link
|
<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>
|
||||||
key={message.id}
|
)) : <div className="empty">还没有聊天记录。</div>) : (binding.elderDevice.usageEvents.length ? binding.elderDevice.usageEvents.map((event) => (
|
||||||
href={getMessageHref(message.publicId)}
|
<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>
|
||||||
className="block rounded-[24px] bg-[var(--paper-soft)] px-5 py-4 transition hover:bg-white"
|
)) : <div className="empty">暂时还没有使用记录。</div>)}
|
||||||
>
|
|
||||||
<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.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>
|
</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>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,146 +1,52 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ChevronRight, Smartphone } from "lucide-react";
|
||||||
|
|
||||||
import { BindDeviceForm } from "@/components/dashboard-actions";
|
import { BindDeviceForm } from "@/components/dashboard-actions";
|
||||||
import { PanelShell } from "@/components/panel-shell";
|
import { PanelShell } from "@/components/panel-shell";
|
||||||
import { PwaControls } from "@/components/pwa-controls";
|
|
||||||
import { getCaregiverDevices } from "@/lib/caregiver-panel";
|
import { getCaregiverDevices } from "@/lib/caregiver-panel";
|
||||||
import { formatDateTime, getDeviceName, truncateText } from "@/lib/panel-format";
|
import { formatDateTime, getDeviceName } from "@/lib/panel-format";
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
|
import { getCaregiverDisplayName } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function DevicesPage() {
|
export default async function DevicesPage() {
|
||||||
const caregiver = await requireCaregiverSession("/devices");
|
const caregiver = await requireCaregiverSession("/devices");
|
||||||
const devices = await getCaregiverDevices(caregiver.id);
|
const devices = await getCaregiverDevices(caregiver.id);
|
||||||
|
const unread = devices.reduce((n, d) => n + d.elderUnreadCount, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell currentPath="/devices" title="我的设备" caregiverLabel={getCaregiverDisplayName(caregiver)} unreadCount={unread}
|
||||||
currentPath="/devices"
|
actions={<Link href="/scan" className="btn small">扫码绑定</Link>}>
|
||||||
title="设备管理和绑定入口单独放在这里。"
|
<section className="card">
|
||||||
description="手动粘贴设备码、扫码绑定、查看某台设备的专属页,都统一放在设备页,不再和首页摘要、活动记录混在一起。"
|
{devices.length ? devices.map((binding) => (
|
||||||
caregiverToken={caregiver.sessionToken}
|
<Link key={binding.id} href={`/devices/${binding.elderDevice.deviceUuid}`} className="list-row" style={{ alignItems: "center", gap: 12 }}>
|
||||||
actions={<PwaControls />}
|
<span className="device-icon"><Smartphone size={19} strokeWidth={1.8} /></span>
|
||||||
>
|
<span className="row-main">
|
||||||
<section className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
<BindDeviceForm />
|
<span className="row-title">{getDeviceName(binding.alias, binding.elderDevice.displayName)}</span>
|
||||||
|
<span className={`dot ${binding.elderDevice.lastSeenAt ? "online" : "offline"}`} style={{ marginTop: 0 }} />
|
||||||
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
</span>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<span className="row-meta">{formatDateTime(binding.elderDevice.lastSeenAt)} · 留言 {binding.elderDevice._count.messages} · 对话 {binding.elderDevice._count.conversationTurns} · 服务 {binding.elderDevice._count.toolCalls}</span>
|
||||||
设备入口说明
|
</span>
|
||||||
</p>
|
{binding.elderUnreadCount > 0 ? <span className="nav-badge" style={{ position: "static" }}>{binding.elderUnreadCount}</span> : null}
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
<ChevronRight size={14} color="var(--muted-light)" />
|
||||||
一台设备一个详情页
|
</Link>
|
||||||
</h2>
|
)) : <div className="empty">还没有连接设备,扫描长辈屏幕上的二维码即可添加。</div>}
|
||||||
<div className="mt-4 space-y-3 rounded-[24px] bg-[var(--paper-soft)] p-5 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
<p>1. 先通过扫码或粘贴设备码建立连接。</p>
|
|
||||||
<p>2. 进入设备详情页给这位老人写留言,不再在首页里混排。</p>
|
|
||||||
<p>3. 每台设备都有自己的最近留言、对话片段和工具调用摘要。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-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>
|
</section>
|
||||||
|
|
||||||
{devices.length === 0 ? (
|
<BindDeviceForm />
|
||||||
<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 (
|
<details className="card">
|
||||||
<article
|
<summary className="list-row" style={{ alignItems: "center", fontSize: 13, fontWeight: 600, listStyle: "none" }}>
|
||||||
key={binding.id}
|
<span style={{ flex: 1 }}>如何连接长辈的设备?</span><span className="row-meta" style={{ margin: 0 }}>展开</span>
|
||||||
className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]"
|
</summary>
|
||||||
>
|
<div className="card-pad" style={{ borderTop: "1px solid var(--line)", color: "var(--muted)", fontSize: 12.5, lineHeight: 1.7 }}>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<p>1. 让长辈打开陪伴屏上的「家人连接」页面。</p>
|
||||||
设备详情
|
<p>2. 扫描二维码,或把设备码粘贴到上方输入框。</p>
|
||||||
</p>
|
<p>3. 连接后即可收到长辈的留言、对话与使用动态。</p>
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
</div>
|
||||||
{getDeviceName(binding.elderDevice.displayName)}
|
</details>
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
|
||||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
|
||||||
最近在线:{formatDateTime(binding.elderDevice.lastSeenAt)}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
|
||||||
老人新留言 {binding.elderUnreadCount}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
|
||||||
待老人查看 {binding.familyUnreadCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
|
||||||
<p className="text-xs text-[var(--muted)]">留言总数</p>
|
|
||||||
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
|
||||||
{binding.elderDevice._count.messages}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
|
||||||
<p className="text-xs text-[var(--muted)]">对话条数</p>
|
|
||||||
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
|
||||||
{binding.elderDevice._count.conversationTurns}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[22px] bg-[var(--paper-soft)] px-4 py-4">
|
|
||||||
<p className="text-xs text-[var(--muted)]">工具调用</p>
|
|
||||||
<p className="mt-2 text-2xl font-semibold text-[var(--ink)]">
|
|
||||||
{binding.elderDevice._count.toolCalls}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 rounded-[24px] bg-[var(--paper-soft)] px-5 py-4">
|
|
||||||
<p className="text-sm font-semibold text-[var(--ink)]">最近一条动态</p>
|
|
||||||
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
{latestMessage
|
|
||||||
? truncateText(latestMessage.content, 96)
|
|
||||||
: "这台设备还没有同步到新的留言或问候。"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
|
||||||
<Link
|
|
||||||
href={`/devices/${binding.elderDevice.deviceUuid}`}
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
|
||||||
>
|
|
||||||
打开设备页
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/messages"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
|
|
||||||
>
|
|
||||||
去留言中心
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--paper: #f4ece0;
|
--canvas: #e7e4df;
|
||||||
--paper-soft: #fbf7f1;
|
--paper: #f5f4f2;
|
||||||
--paper-strong: #fffdf8;
|
--surface: #ffffff;
|
||||||
--ink: #241b14;
|
--surface-soft: #faf9f8;
|
||||||
--ink-soft: #3d2d20;
|
--ink: #211e1a;
|
||||||
--muted: #726153;
|
--ink-soft: #3d3833;
|
||||||
--line: rgba(85, 60, 42, 0.15);
|
--muted: #8b857c;
|
||||||
--copper: #c76733;
|
--muted-light: #b5afa6;
|
||||||
--copper-deep: #964722;
|
--line: rgba(33, 30, 26, 0.08);
|
||||||
--olive: #76845e;
|
--line-strong: rgba(33, 30, 26, 0.15);
|
||||||
--olive-deep: #596946;
|
--accent: #c05a1e;
|
||||||
|
--accent-hover: #96431a;
|
||||||
|
--accent-soft: rgba(192, 90, 30, 0.09);
|
||||||
|
--copper: var(--accent);
|
||||||
|
--copper-deep: var(--accent-hover);
|
||||||
|
--paper-soft: var(--surface-soft);
|
||||||
|
--olive: #65765a;
|
||||||
|
--olive-deep: #4f6046;
|
||||||
|
--success: #3f9d63;
|
||||||
|
--danger: #b4482c;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--font-body: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
--font-display: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -20,47 +34,176 @@
|
|||||||
--font-sans: var(--font-body);
|
--font-sans: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* { box-sizing: border-box; }
|
||||||
box-sizing: border-box;
|
html { background: var(--canvas); }
|
||||||
|
body { margin: 0; color: var(--ink); background: var(--canvas); font-family: var(--font-body); }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
button, input, textarea, select { font: inherit; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:disabled { cursor: not-allowed; opacity: .6; }
|
||||||
|
textarea { resize: none; }
|
||||||
|
*::-webkit-scrollbar { display: none; }
|
||||||
|
::selection { background: rgba(192, 90, 30, 0.18); }
|
||||||
|
|
||||||
|
.app-frame {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 430px;
|
||||||
|
min-height: 100dvh;
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--paper);
|
||||||
|
box-shadow: 0 0 0 1px rgba(33, 30, 26, .03);
|
||||||
|
}
|
||||||
|
.app-content { min-height: 100dvh; padding: 22px 18px 110px; }
|
||||||
|
.app-content.detail { padding-top: 0; padding-bottom: 28px; }
|
||||||
|
.app-stack { display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 2px; }
|
||||||
|
.page-heading h1 { margin: 0; font-size: 21px; line-height: 1.35; font-weight: 700; letter-spacing: .01em; }
|
||||||
|
.page-heading p { margin: 4px 0 0; color: var(--muted); font-size: 12.5px; line-height: 1.5; }
|
||||||
|
.avatar { display: flex; width: 36px; height: 36px; flex: none; align-items: center; justify-content: center; border: 0; border-radius: 50%; background: var(--ink); color: var(--paper); font-size: 14px; font-weight: 600; }
|
||||||
|
.detail-header { position: sticky; top: 0; z-index: 5; display: flex; min-height: 58px; align-items: center; gap: 7px; padding: 11px 0; background: rgba(245, 244, 242, .96); backdrop-filter: blur(10px); }
|
||||||
|
.detail-header h1 { margin: 0; flex: 1; font-size: 16px; font-weight: 700; }
|
||||||
|
.back-button { display: flex; width: 32px; height: 32px; align-items: center; justify-content: center; border: 0; background: transparent; color: var(--ink); }
|
||||||
|
|
||||||
|
.card { overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); }
|
||||||
|
.card-pad { padding: 15px 16px; }
|
||||||
|
.list-row { display: flex; width: 100%; align-items: flex-start; gap: 11px; padding: 12px 16px; border: 0; border-top: 1px solid var(--line); background: transparent; color: var(--ink); text-align: left; }
|
||||||
|
.list-row:first-child { border-top: 0; }
|
||||||
|
.list-row:hover { background: var(--surface-soft); }
|
||||||
|
.row-main { flex: 1; min-width: 0; }
|
||||||
|
.row-title { display: block; color: var(--ink); font-size: 14px; line-height: 1.5; font-weight: 600; }
|
||||||
|
.row-meta { display: flex; align-items: center; gap: 6px; margin-top: 3px; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||||
|
.dot { width: 7px; height: 7px; flex: none; margin-top: 6px; border-radius: 50%; background: var(--accent); }
|
||||||
|
.dot.online { background: var(--success); animation: pulse 2.4s ease-in-out infinite; }
|
||||||
|
.dot.offline { background: #c7c2ba; }
|
||||||
|
.tag { display: inline-flex; align-items: center; border-radius: 5px; padding: 2px 7px; background: var(--accent-soft); color: var(--accent); font-size: 11px; font-weight: 600; }
|
||||||
|
.tag.success { background: rgba(63, 157, 99, .1); color: var(--success); }
|
||||||
|
.section-title { display: flex; align-items: baseline; justify-content: space-between; padding: 0 2px 9px; }
|
||||||
|
.section-title h2 { margin: 0; font-size: 15px; font-weight: 700; }
|
||||||
|
.section-title > span { color: var(--muted-light); font-size: 12px; }
|
||||||
|
.section-title a { color: var(--accent); font-size: 12.5px; font-weight: 600; }
|
||||||
|
|
||||||
|
.stats { display: grid; overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); }
|
||||||
|
.stat { padding: 13px 4px; text-align: center; }
|
||||||
|
.stat + .stat { border-left: 1px solid var(--line); }
|
||||||
|
.stat strong { display: block; font-size: 21px; line-height: 1.2; font-weight: 700; }
|
||||||
|
.stat:first-child strong, .stat.accent strong { color: var(--accent); }
|
||||||
|
.stat span { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; }
|
||||||
|
|
||||||
|
.button-row { display: flex; gap: 8px; }
|
||||||
|
.btn { display: inline-flex; min-height: 44px; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--line-strong); border-radius: var(--radius-md); padding: 0 13px; background: var(--surface); color: var(--ink); font-size: 14px; font-weight: 600; white-space: nowrap; }
|
||||||
|
.btn:hover { background: var(--surface-soft); }
|
||||||
|
.btn.primary { border-color: var(--accent); background: var(--accent); color: #fff; }
|
||||||
|
.btn.primary:hover { border-color: var(--accent-hover); background: var(--accent-hover); }
|
||||||
|
.btn.dark { border-color: var(--ink); background: var(--ink); color: #fff; }
|
||||||
|
.btn.small { min-height: 32px; border-radius: 8px; padding: 0 13px; font-size: 12.5px; }
|
||||||
|
.btn.block { width: 100%; }
|
||||||
|
.btn.muted { color: var(--muted); }
|
||||||
|
|
||||||
|
.segment { display: flex; gap: 2px; padding: 3px; border-radius: 11px; background: rgba(33, 30, 26, .06); }
|
||||||
|
.segment a, .segment button { flex: 1; height: 34px; border: 0; border-radius: 8px; background: transparent; color: var(--muted); font-size: 13px; font-weight: 600; text-align: center; line-height: 34px; }
|
||||||
|
.segment .active { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(0, 0, 0, .08); }
|
||||||
|
|
||||||
|
.field-label { display: block; margin: 0 0 7px; color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: .02em; }
|
||||||
|
.field { width: 100%; height: 44px; border: 1px solid var(--line-strong); border-radius: 10px; padding: 0 13px; outline: 0; background: #fff; color: var(--ink); font-size: 14px; }
|
||||||
|
.field::placeholder { color: var(--muted-light); }
|
||||||
|
.field:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||||
|
textarea.field { height: auto; min-height: 78px; padding: 11px 13px; line-height: 1.6; background: var(--surface-soft); }
|
||||||
|
select.field {
|
||||||
|
appearance: none;
|
||||||
|
padding-right: 34px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #fff;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15' viewBox='0 0 24 24' fill='none' stroke='%238b857c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-position: right 11px center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
.form-stack { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.form-grid { display: grid; gap: 10px; }
|
||||||
|
.form-grid.two { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||||
|
.form-grid.action { grid-template-columns: minmax(0, 1fr) 110px; }
|
||||||
|
.form-grid.publish { grid-template-columns: minmax(0, 1fr) 96px; }
|
||||||
|
.form-grid.memo-actions { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 82px; }
|
||||||
|
.form-notice { margin: 9px 0 0; color: var(--success); font-size: 12px; line-height: 1.5; }
|
||||||
|
|
||||||
|
.elder-status { overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); }
|
||||||
|
.elder-status-main { display: flex; align-items: center; gap: 12px; padding: 15px 16px; }
|
||||||
|
.elder-avatar { display: flex; width: 46px; height: 46px; flex: none; align-items: center; justify-content: center; border-radius: 50%; background: rgba(101, 118, 90, .12); color: var(--olive); font-size: 18px; font-weight: 700; }
|
||||||
|
.elder-name { display: flex; align-items: center; gap: 7px; font-size: 15px; font-weight: 700; }
|
||||||
|
.online-label { font-size: 12px; font-weight: 600; }
|
||||||
|
.elder-meta { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; }
|
||||||
|
.checkin-action { border-top: 1px solid var(--line); padding: 12px 16px 14px; }
|
||||||
|
.checkin-action .form-notice { text-align: center; }
|
||||||
|
|
||||||
|
.timeline-item { padding: 13px 16px; border-top: 1px solid var(--line); }
|
||||||
|
.timeline-item:first-child { border-top: 0; }
|
||||||
|
.timeline-top { display: flex; align-items: baseline; gap: 8px; color: var(--muted); font-size: 13px; font-weight: 600; }
|
||||||
|
.timeline-top.strong { color: var(--ink); font-size: 13.5px; font-weight: 700; }
|
||||||
|
.timeline-time { margin-left: auto; color: var(--muted-light); font-size: 11.5px; font-weight: 400; white-space: nowrap; }
|
||||||
|
.timeline-body { margin: 5px 0 0; color: var(--ink-soft); font-size: 13px; line-height: 1.6; }
|
||||||
|
.timeline-item > .row-meta { margin-top: 5px; }
|
||||||
|
.timeline-item > .button-row { margin-top: 10px; }
|
||||||
|
.alert-title { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); font-size: 13.5px; font-weight: 700; }
|
||||||
|
.alert-title.red { color: #b33a2b; }
|
||||||
|
|
||||||
|
.member-avatar { display: flex; width: 36px; height: 36px; flex: none; align-items: center; justify-content: center; border-radius: 50%; font-size: 14px; font-weight: 700; }
|
||||||
|
.member-role { flex: none; border-radius: 5px; padding: 2px 7px; background: rgba(33, 30, 26, .05); color: var(--muted); font-size: 11px; font-weight: 600; }
|
||||||
|
.feature-row { align-items: center; gap: 12px; padding: 14px 16px; }
|
||||||
|
.feature-icon { display: flex; width: 40px; height: 40px; flex: none; align-items: center; justify-content: center; border-radius: 11px; background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.feature-icon.olive { background: rgba(101, 118, 90, .1); color: var(--olive); }
|
||||||
|
.feature-icon.muted { background: rgba(33, 30, 26, .05); color: var(--muted); }
|
||||||
|
.feature-disabled { opacity: .55; }
|
||||||
|
.chevron { flex: none; color: var(--muted-light); }
|
||||||
|
.count-badge { min-width: 18px; height: 18px; border-radius: 9px; padding: 0 5px; background: var(--accent); color: #fff; font-size: 11px; font-weight: 700; line-height: 18px; text-align: center; }
|
||||||
|
|
||||||
|
.settings-profile { display: flex; align-items: center; gap: 13px; padding: 16px; }
|
||||||
|
.settings-profile .avatar { width: 48px; height: 48px; font-size: 18px; }
|
||||||
|
.settings-row { display: flex; width: 100%; align-items: center; gap: 12px; padding: 13px 16px; border: 0; border-top: 1px solid var(--line); background: transparent; color: var(--ink); text-align: left; }
|
||||||
|
.settings-row:first-child { border-top: 0; }
|
||||||
|
.settings-row:hover { background: var(--surface-soft); }
|
||||||
|
.settings-icon { display: flex; width: 36px; height: 36px; flex: none; align-items: center; justify-content: center; border-radius: 10px; background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.settings-icon.olive { background: rgba(101, 118, 90, .1); color: var(--olive); }
|
||||||
|
.settings-icon.muted { background: rgba(33, 30, 26, .05); color: var(--muted); }
|
||||||
|
.toggle { width: 46px; height: 27px; flex: none; position: relative; border: 0; border-radius: 14px; background: rgba(33, 30, 26, .18); transition: background .2s; }
|
||||||
|
.toggle.on { background: var(--success); }
|
||||||
|
.toggle-knob { position: absolute; top: 3px; left: 3px; width: 21px; height: 21px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .2); transition: left .2s; }
|
||||||
|
.toggle.on .toggle-knob { left: 22px; }
|
||||||
|
|
||||||
|
.empty { padding: 34px 20px; color: var(--muted); text-align: center; font-size: 13px; line-height: 1.7; }
|
||||||
|
.muted-note { margin: 0; color: var(--muted-light); font-size: 11.5px; line-height: 1.6; }
|
||||||
|
.bottom-nav { position: fixed; z-index: 20; right: 0; bottom: 0; left: 0; display: grid; grid-template-columns: repeat(5, 1fr); width: 100%; max-width: 430px; margin: 0 auto; padding: 8px 4px calc(18px + env(safe-area-inset-bottom)); border-top: 1px solid var(--line); background: rgba(255, 255, 255, .94); backdrop-filter: blur(12px); }
|
||||||
|
.nav-item { position: relative; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 5px 0; color: #9a948b; font-size: 10.5px; font-weight: 600; }
|
||||||
|
.nav-item.active { color: var(--accent); }
|
||||||
|
.nav-badge { position: absolute; top: 1px; left: calc(50% + 7px); min-width: 16px; height: 16px; border-radius: 8px; padding: 0 4px; background: var(--accent); color: #fff; font-size: 10px; font-weight: 700; line-height: 16px; text-align: center; }
|
||||||
|
.device-icon { display: flex; width: 40px; height: 40px; flex: none; align-items: center; justify-content: center; border-radius: 10px; background: rgba(192, 90, 30, .08); color: var(--accent); }
|
||||||
|
|
||||||
|
.settings-controls > * { border: 1px solid var(--line) !important; border-radius: 14px !important; background: #fff !important; padding: 15px 16px !important; box-shadow: none !important; }
|
||||||
|
.settings-controls h2, .settings-controls h3 { font-family: var(--font-body) !important; font-size: 15px !important; line-height: 1.45 !important; }
|
||||||
|
.settings-controls p { font-size: 12px !important; line-height: 1.65 !important; }
|
||||||
|
.device-manage summary { list-style: none; }
|
||||||
|
.device-manage[open] summary { margin-bottom: 10px; }
|
||||||
|
.device-manage > :not(summary) { border: 1px solid var(--line) !important; border-radius: 14px !important; background: #fff !important; padding: 15px 16px !important; box-shadow: none !important; }
|
||||||
|
.scan-panel > div { border: 0 !important; border-radius: 0 !important; background: transparent !important; padding: 0 !important; box-shadow: none !important; }
|
||||||
|
.scan-panel > div > p:first-child, .scan-panel > div > h2, .scan-panel > div > h2 + p { display: none !important; }
|
||||||
|
.scan-panel video { border-radius: 14px; }
|
||||||
|
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
|
||||||
|
@keyframes route-progress-slide { 0% { transform: translateX(-140%); } 100% { transform: translateX(240%); } }
|
||||||
|
.route-progress-bar { animation: route-progress-slide 1.15s ease-in-out infinite; background: var(--accent) !important; }
|
||||||
|
|
||||||
|
@media (max-width: 390px) {
|
||||||
|
.form-grid.memo-actions { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||||
|
.form-grid.memo-actions .btn { grid-column: 1 / -1; }
|
||||||
|
.stats .stat strong { font-size: 19px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
@media (min-width: 700px) {
|
||||||
background: #ecdfcf;
|
.app-frame { min-height: calc(100dvh - 72px); margin: 36px auto; border-radius: 30px; box-shadow: 0 24px 70px rgba(33, 30, 26, .16), 0 0 0 1px rgba(33, 30, 26, .05); }
|
||||||
|
.app-content { min-height: calc(100dvh - 72px); }
|
||||||
|
.bottom-nav { bottom: 36px; border-radius: 0 0 30px 30px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
color: var(--ink);
|
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||||
font-family: var(--font-body), sans-serif;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.82), transparent 34%),
|
|
||||||
radial-gradient(circle at 88% 10%, rgba(205, 126, 76, 0.22), transparent 22%),
|
|
||||||
linear-gradient(180deg, #f6eee1 0%, #efe2d1 52%, #e8d6c3 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input,
|
|
||||||
textarea,
|
|
||||||
select {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
background: rgba(199, 103, 51, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.paper-panel {
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 249, 241, 0.84)),
|
|
||||||
repeating-linear-gradient(
|
|
||||||
135deg,
|
|
||||||
rgba(198, 163, 127, 0.06),
|
|
||||||
rgba(198, 163, 127, 0.06) 10px,
|
|
||||||
rgba(255, 255, 255, 0.08) 10px,
|
|
||||||
rgba(255, 255, 255, 0.08) 20px
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 type { Metadata, Viewport } from "next";
|
||||||
import { Noto_Sans_SC, Noto_Serif_SC } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const bodyFont = Noto_Sans_SC({
|
import { NavigationProgressProvider } from "@/components/navigation-progress";
|
||||||
variable: "--font-body",
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: ["400", "500", "700"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayFont = Noto_Serif_SC({
|
|
||||||
variable: "--font-display",
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: ["400", "600", "700"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL("https://digital-human.xn--876a.net"),
|
metadataBase: new URL("https://digital-human.xn--876a.net"),
|
||||||
title: "家人连线台",
|
title: "安智伴 家人连线台",
|
||||||
description: "给老人设备做绑定、查看留言、追踪陪伴记录的家属端。",
|
description: "随时了解长辈的近况,查看留言、使用动态,给长辈写问候。",
|
||||||
manifest: "/manifest.webmanifest",
|
manifest: "/manifest.webmanifest",
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: {
|
||||||
{
|
url: "/app-icon.webp",
|
||||||
url: "/pwa/icon-192x192.png",
|
sizes: "512x512",
|
||||||
sizes: "192x192",
|
type: "image/webp",
|
||||||
type: "image/png",
|
},
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "/pwa/icon-512x512.png",
|
|
||||||
sizes: "512x512",
|
|
||||||
type: "image/png",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
apple: [
|
apple: [
|
||||||
{
|
{
|
||||||
url: "/pwa/apple-touch-icon.png",
|
url: "/pwa/apple-touch-icon.webp",
|
||||||
sizes: "180x180",
|
sizes: "180x180",
|
||||||
type: "image/png",
|
type: "image/webp",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
shortcut: "/pwa/icon-192x192.png",
|
shortcut: "/app-icon.webp",
|
||||||
},
|
},
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: "default",
|
statusBarStyle: "default",
|
||||||
title: "家人连线台",
|
title: "安智伴 家人连线台",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: "#35548d",
|
themeColor: "#F5F4F2",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -58,8 +40,10 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN" className={`${bodyFont.variable} ${displayFont.variable}`}>
|
<html lang="zh-CN">
|
||||||
<body className="min-h-screen">{children}</body>
|
<body className="min-h-screen">
|
||||||
|
<NavigationProgressProvider>{children}</NavigationProgressProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
name: "家人连线台",
|
name: "安智伴 家人连线台",
|
||||||
short_name: "家人连线台",
|
short_name: "安智伴 家人连线台",
|
||||||
description: "给老人设备做绑定、查看留言、追踪陪伴记录的家属端。",
|
description: "随时了解长辈的近况,查看留言、使用动态,给长辈写问候。",
|
||||||
start_url: "/",
|
start_url: "/",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
background_color: "#f6eee1",
|
background_color: "#f6eee1",
|
||||||
theme_color: "#35548d",
|
theme_color: "#FBFAF6",
|
||||||
lang: "zh-CN",
|
lang: "zh-CN",
|
||||||
categories: ["utilities", "lifestyle", "health"],
|
categories: ["utilities", "lifestyle", "health"],
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: "/pwa/icon-192x192.png",
|
src: "/pwa/icon-192x192.webp",
|
||||||
sizes: "192x192",
|
sizes: "192x192",
|
||||||
type: "image/png",
|
type: "image/webp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "/pwa/icon-512x512.png",
|
src: "/pwa/icon-512x512.webp",
|
||||||
sizes: "512x512",
|
sizes: "512x512",
|
||||||
type: "image/png",
|
type: "image/webp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "/pwa/icon-maskable-512x512.png",
|
src: "/pwa/icon-maskable-512x512.webp",
|
||||||
sizes: "512x512",
|
sizes: "512x512",
|
||||||
type: "image/png",
|
type: "image/webp",
|
||||||
purpose: "maskable",
|
purpose: "maskable",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,118 +3,35 @@ import { notFound } from "next/navigation";
|
|||||||
|
|
||||||
import { PanelShell } from "@/components/panel-shell";
|
import { PanelShell } from "@/components/panel-shell";
|
||||||
import { getCaregiverMessageDetail } from "@/lib/caregiver-panel";
|
import { getCaregiverMessageDetail } from "@/lib/caregiver-panel";
|
||||||
import {
|
import { formatDateTime, getDeviceName, getMessageDirectionLabel, getMessageStatusLabel } from "@/lib/panel-format";
|
||||||
formatDateTime,
|
|
||||||
getDeviceName,
|
|
||||||
getImportanceLabel,
|
|
||||||
getMessageDirectionLabel,
|
|
||||||
getMessageStatusLabel,
|
|
||||||
} from "@/lib/panel-format";
|
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
|
import { getCaregiverDisplayName } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type MessageDetailPageProps = {
|
export default async function MessageDetailPage({ params }: PageProps<"/messages/[messageId]">) {
|
||||||
params: Promise<{ messageId: string }>;
|
const publicId = Number.parseInt((await params).messageId, 10);
|
||||||
};
|
if (!Number.isFinite(publicId) || publicId <= 0) notFound();
|
||||||
|
|
||||||
export default async function MessageDetailPage({ params }: MessageDetailPageProps) {
|
|
||||||
const { messageId } = await params;
|
|
||||||
const publicId = Number.parseInt(messageId, 10);
|
|
||||||
|
|
||||||
if (!Number.isFinite(publicId) || publicId <= 0) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const caregiver = await requireCaregiverSession(`/messages/${publicId}`);
|
const caregiver = await requireCaregiverSession(`/messages/${publicId}`);
|
||||||
const message = await getCaregiverMessageDetail(caregiver.id, publicId);
|
const message = await getCaregiverMessageDetail(caregiver.id, publicId);
|
||||||
|
if (!message) notFound();
|
||||||
if (!message) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell currentPath="/messages" title={`留言 #${message.publicId}`} caregiverLabel={getCaregiverDisplayName(caregiver)} backHref="/messages">
|
||||||
currentPath="/messages"
|
<article className="card card-pad" style={{ paddingTop: 16, paddingBottom: 16 }}>
|
||||||
title={`留言 #${message.publicId}`}
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||||
description="这是单条留言详情页。无论从通知点击进来,还是从留言中心点开,都能在这里看到完整内容与所属设备信息。"
|
<span className="tag" style={{ color: "var(--muted)", background: "rgba(32,29,26,.06)" }}>{getMessageDirectionLabel(message.direction)}</span>
|
||||||
caregiverToken={caregiver.sessionToken}
|
{message.importance !== "NORMAL" ? <span className="tag">重要</span> : null}
|
||||||
>
|
<span className="tag" style={{ color: "var(--muted)", background: "rgba(32,29,26,.06)" }}>{getMessageStatusLabel(message)}</span>
|
||||||
<section className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
</div>
|
||||||
<article className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
<p style={{ margin: "12px 0 0", fontSize: 17, lineHeight: 1.65 }}>{message.content}</p>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted)]">
|
</article>
|
||||||
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1 font-semibold text-[var(--ink)]">
|
<section className="card">
|
||||||
#{message.publicId}
|
<div className="list-row"><span style={{ color: "var(--muted)" }}>所属设备</span><strong style={{ marginLeft: "auto", fontSize: 13 }}>{getDeviceName(message.elderDevice.displayName)}</strong></div>
|
||||||
</span>
|
<div className="list-row"><span style={{ color: "var(--muted)" }}>时间</span><strong style={{ marginLeft: "auto", fontSize: 13 }}>{formatDateTime(message.createdAt)}</strong></div>
|
||||||
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
|
<div className="list-row"><span style={{ color: "var(--muted)" }}>送达方式</span><strong style={{ marginLeft: "auto", fontSize: 13 }}>数字人口播</strong></div>
|
||||||
{getMessageDirectionLabel(message.direction)}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
|
|
||||||
{getImportanceLabel(message.importance)}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-full bg-[var(--paper-soft)] px-3 py-1">
|
|
||||||
{getMessageStatusLabel(message)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="mt-5 font-[family-name:var(--font-display)] text-4xl leading-[1.2] text-[var(--ink)]">
|
|
||||||
{message.content}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{message.recipientRelation ? (
|
|
||||||
<p className="mt-4 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
目标对象:{message.recipientRelation}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<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>
|
</section>
|
||||||
|
<Link href={`/devices/${message.elderDevice.deviceUuid}`} className="btn primary block">回一句问候</Link>
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,146 +1,43 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { PenLine } from "lucide-react";
|
||||||
|
|
||||||
import { PanelShell } from "@/components/panel-shell";
|
import { PanelShell } from "@/components/panel-shell";
|
||||||
import { PwaControls } from "@/components/pwa-controls";
|
|
||||||
import { getCaregiverMessages } from "@/lib/caregiver-panel";
|
import { getCaregiverMessages } from "@/lib/caregiver-panel";
|
||||||
import {
|
import { formatDateTime, getDeviceName, getMessageHref, getMessageStatusLabel, truncateText } from "@/lib/panel-format";
|
||||||
formatDateTime,
|
|
||||||
getDeviceName,
|
|
||||||
getImportanceLabel,
|
|
||||||
getMessageDirectionLabel,
|
|
||||||
getMessageHref,
|
|
||||||
getMessageStatusLabel,
|
|
||||||
truncateText,
|
|
||||||
} from "@/lib/panel-format";
|
|
||||||
import { requireCaregiverSession } from "@/lib/page-auth";
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
|
import { getCaregiverDisplayName } from "@/lib/session";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function MessagesPage() {
|
export default async function MessagesPage({ searchParams }: PageProps<"/messages">) {
|
||||||
const caregiver = await requireCaregiverSession("/messages");
|
const caregiver = await requireCaregiverSession("/messages");
|
||||||
const { messages, unreadIncomingCount, unreadOutgoingCount } =
|
const { messages, unreadIncomingCount } = await getCaregiverMessages(caregiver.id);
|
||||||
await getCaregiverMessages(caregiver.id);
|
const filter = (await searchParams).filter === "out" ? "out" : "in";
|
||||||
|
const items = messages.filter((m) => filter === "in" ? m.direction === "ELDER_TO_FAMILY" : m.direction === "FAMILY_TO_ELDER");
|
||||||
const incomingMessages = messages.filter(
|
|
||||||
(message) => message.direction === "ELDER_TO_FAMILY",
|
|
||||||
);
|
|
||||||
const outgoingMessages = messages.filter(
|
|
||||||
(message) => message.direction === "FAMILY_TO_ELDER",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell currentPath="/messages" title="留言板" caregiverLabel={getCaregiverDisplayName(caregiver)} unreadCount={unreadIncomingCount}
|
||||||
currentPath="/messages"
|
actions={<Link href="/devices" className="btn primary small"><PenLine size={14}/>写留言</Link>}>
|
||||||
title="留言独立成页后,每条消息都能用编号直达。"
|
<div className="segment">
|
||||||
description="不论是老人留给家里的话,还是家属写给老人的问候,都集中在这里。点击任何一条卡片,都会进入带 URL 编号的详情页。"
|
<Link href="/messages?filter=in" className={filter === "in" ? "active" : ""}>收到 · {unreadIncomingCount} 未读</Link>
|
||||||
caregiverToken={caregiver.sessionToken}
|
<Link href="/messages?filter=out" className={filter === "out" ? "active" : ""}>发出</Link>
|
||||||
actions={<PwaControls />}
|
</div>
|
||||||
>
|
<section className="card">
|
||||||
<section className="grid gap-4 md:grid-cols-3">
|
{items.length ? items.map((message) => (
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
<Link key={message.id} href={getMessageHref(message.publicId)} className="list-row">
|
||||||
<p className="text-sm text-[var(--muted)]">全部留言</p>
|
<span className="dot" style={{ background: message.readAt ? "transparent" : "var(--accent)" }} />
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<span className="row-main">
|
||||||
{messages.length}
|
<span className="row-meta" style={{ marginTop: 0 }}>
|
||||||
</p>
|
<strong style={{ color: "var(--ink-soft)" }}>{getDeviceName(message.elderDevice.displayName)}</strong>
|
||||||
</div>
|
{message.importance !== "NORMAL" ? <span className="tag">重要</span> : null}
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
<span style={{ marginLeft: "auto", color: "var(--muted-light)" }}>{formatDateTime(message.createdAt)}</span>
|
||||||
<p className="text-sm text-[var(--muted)]">老人给家里的未读留言</p>
|
</span>
|
||||||
<p className="mt-3 font-[family-name:var(--font-display)] text-4xl text-[var(--ink)]">
|
<span className="row-title" style={{ marginTop: 4, fontWeight: message.readAt ? 400 : 600 }}>{truncateText(message.content, 82)}</span>
|
||||||
{unreadIncomingCount}
|
<span className="row-meta">#{message.publicId} · {getMessageStatusLabel(message)}</span>
|
||||||
</p>
|
</span>
|
||||||
</div>
|
</Link>
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/88 p-5">
|
)) : <div className="empty">这一侧暂时还没有留言。</div>}
|
||||||
<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>
|
</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}
|
|
||||||
</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>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,239 +1,2 @@
|
|||||||
import Link from "next/link";
|
import { redirect } from "next/navigation";
|
||||||
|
export default function Home() { redirect("/guard"); }
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,40 +1,23 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { PanelShell } from "@/components/panel-shell";
|
||||||
import { ScanClient } from "@/components/scan-client";
|
import { ScanClient } from "@/components/scan-client";
|
||||||
|
import { requireCaregiverSession } from "@/lib/page-auth";
|
||||||
|
import { getCaregiverDisplayName } from "@/lib/session";
|
||||||
|
|
||||||
export default function ScanPage() {
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ScanPage() {
|
||||||
|
const caregiver = await requireCaregiverSession("/scan");
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto min-h-screen max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
|
<PanelShell currentPath="/scan" title="扫码绑定" caregiverLabel={getCaregiverDisplayName(caregiver)} backHref="/devices">
|
||||||
<section className="grid gap-6 lg:grid-cols-[0.82fr_1.18fr]">
|
<div className="scan-panel"><ScanClient /></div>
|
||||||
<div className="rounded-[36px] border border-white/70 bg-white/78 p-6 shadow-[0_30px_70px_rgba(115,76,42,0.14)] backdrop-blur">
|
<section className="card card-pad" style={{ color: "var(--muted)", fontSize: 12.5, lineHeight: 1.7 }}>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<p style={{ margin: 0 }}>1. 让长辈打开陪伴屏上的「家人连接」页面。</p>
|
||||||
扫码绑定
|
<p style={{ margin: "4px 0 0" }}>2. 将镜头对准屏幕上的二维码,保持一两秒。</p>
|
||||||
</p>
|
<p style={{ margin: "4px 0 0" }}>3. 识别成功后自动完成绑定并返回设备页。</p>
|
||||||
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl leading-[1.15] text-[var(--ink)]">
|
|
||||||
对准老人手机里的家人连接二维码
|
|
||||||
</h1>
|
|
||||||
<p className="mt-4 text-base leading-8 text-[var(--muted)]">
|
|
||||||
扫描成功后,系统会自动把老人的本机 UUID 绑定到当前家属账号。之后,您就能看见老人的留言、最近对话和使用记录。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-6 space-y-3 rounded-[28px] bg-[var(--paper-soft)] p-5 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
<p>1. 让老人打开安卓端的家人连接页。</p>
|
|
||||||
<p>2. 把镜头对准屏幕上的二维码,停稳一两秒。</p>
|
|
||||||
<p>3. 绑定成功后会自动跳回控制台。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
|
||||||
<Link
|
|
||||||
href="/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 />
|
|
||||||
</section>
|
</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 Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { startTransition, useState } from "react";
|
import { startTransition, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { useNavigationProgress } from "@/components/navigation-progress";
|
||||||
|
|
||||||
export function BindDeviceForm() {
|
export function BindDeviceForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { beginNavigationProgress } = useNavigationProgress();
|
||||||
const [rawCode, setRawCode] = useState("");
|
const [rawCode, setRawCode] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
@@ -14,84 +17,42 @@ export function BindDeviceForm() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!rawCode.trim()) {
|
if (!rawCode.trim()) {
|
||||||
setNotice("先把老人二维码里的设备码贴进来。\n");
|
setNotice("请先粘贴长辈手机上的设备码。\n");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setNotice("正在建立连接,请稍候……");
|
setNotice("正在前往绑定页面,请稍候……");
|
||||||
|
|
||||||
try {
|
startTransition(() => {
|
||||||
const response = await fetch("/api/family/bind", {
|
beginNavigationProgress();
|
||||||
method: "POST",
|
router.push(`/bind?deviceUuid=${encodeURIComponent(rawCode.trim())}`);
|
||||||
headers: {
|
});
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ deviceUuid: rawCode }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = (await response.json().catch(() => null)) as
|
|
||||||
| { error?: string }
|
|
||||||
| null;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(payload?.error || "连接失败,请稍后再试。");
|
|
||||||
}
|
|
||||||
|
|
||||||
setNotice("已经连上了,这位老人的留言和陪伴记录会出现在下方。\n");
|
|
||||||
setRawCode("");
|
|
||||||
startTransition(() => {
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setNotice(
|
|
||||||
error instanceof Error ? error.message : "连接失败,请稍后再试。",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[28px] border border-white/70 bg-white/80 p-5 shadow-[0_24px_60px_rgba(125,80,46,0.12)] backdrop-blur">
|
<div className="card card-pad">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
<p style={{ margin: 0, fontSize: 13, fontWeight: 600 }}>用设备码添加</p>
|
||||||
<div className="max-w-xl">
|
<form className="button-row" style={{ marginTop: 9 }} onSubmit={handleSubmit}>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
家人连接
|
|
||||||
</p>
|
|
||||||
<h3 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
|
||||||
扫二维码,或者直接贴入设备码
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 text-sm leading-7 text-[var(--muted)]">
|
|
||||||
如果老人已经把二维码发给您,直接扫码最快;如果您拿到的是一段链接或设备码,也可以贴到下面。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/scan"
|
|
||||||
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)]"
|
|
||||||
>
|
|
||||||
打开扫码页
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="mt-5 flex flex-col gap-3 lg:flex-row" onSubmit={handleSubmit}>
|
|
||||||
<input
|
<input
|
||||||
value={rawCode}
|
value={rawCode}
|
||||||
onChange={(event) => setRawCode(event.target.value)}
|
onChange={(event) => setRawCode(event.target.value)}
|
||||||
placeholder="粘贴二维码链接,或者直接贴设备 UUID"
|
placeholder="粘贴长辈屏幕下方的设备码"
|
||||||
className="h-13 flex-1 rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-5 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
|
className="field"
|
||||||
|
style={{ height: 36, flex: 1, minWidth: 0 }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="inline-flex h-13 min-w-36 items-center justify-center rounded-full bg-[var(--olive)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--olive-deep)] disabled:cursor-not-allowed disabled:opacity-60"
|
className="btn dark"
|
||||||
|
style={{ minHeight: 36, flex: "none" }}
|
||||||
>
|
>
|
||||||
{submitting ? "正在连接" : "立即绑定"}
|
{submitting ? "连接中" : "连接"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{notice ? (
|
{notice ? (
|
||||||
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
|
<p style={{ margin: "8px 0 0", whiteSpace: "pre-line", color: "var(--success)", fontSize: 12 }}>
|
||||||
{notice}
|
{notice}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -115,7 +76,7 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!content.trim()) {
|
if (!content.trim()) {
|
||||||
setNotice("先写下想对老人说的话。\n");
|
setNotice("请先写下您想说的话。\n");
|
||||||
setCreatedMessagePublicId(null);
|
setCreatedMessagePublicId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -144,7 +105,7 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
throw new Error(payload?.error || "留言发送失败,请稍后再试。");
|
throw new Error(payload?.error || "留言发送失败,请稍后再试。");
|
||||||
}
|
}
|
||||||
|
|
||||||
setNotice("留言已经写进家人的收件盒,老人下次打开或询问时就能看到。\n");
|
setNotice("留言已发出,长辈下次使用时就能看到。\n");
|
||||||
setCreatedMessagePublicId(
|
setCreatedMessagePublicId(
|
||||||
typeof payload?.publicId === "number" ? payload.publicId : null,
|
typeof payload?.publicId === "number" ? payload.publicId : null,
|
||||||
);
|
);
|
||||||
@@ -164,20 +125,14 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="rounded-[28px] border border-white/70 bg-white/88 p-5" onSubmit={handleSubmit}>
|
<form className="card card-pad" onSubmit={handleSubmit}>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||||
<div>
|
<h4 style={{ margin: 0, fontSize: 13, fontWeight: 600 }}>给长辈留言</h4>
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
|
||||||
给老人留句话
|
|
||||||
</p>
|
|
||||||
<h4 className="mt-2 font-[family-name:var(--font-display)] text-xl text-[var(--ink)]">
|
|
||||||
写一句今天就能被看到的问候
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<select
|
<select
|
||||||
value={importance}
|
value={importance}
|
||||||
onChange={(event) => setImportance(event.target.value)}
|
onChange={(event) => setImportance(event.target.value)}
|
||||||
className="h-11 rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 text-sm text-[var(--ink)] outline-none"
|
className="field"
|
||||||
|
style={{ width: "auto", height: 32, padding: "0 8px", fontSize: 12 }}
|
||||||
>
|
>
|
||||||
<option value="LOW">温和提醒</option>
|
<option value="LOW">温和提醒</option>
|
||||||
<option value="NORMAL">日常问候</option>
|
<option value="NORMAL">日常问候</option>
|
||||||
@@ -191,24 +146,22 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
onChange={(event) => setContent(event.target.value)}
|
onChange={(event) => setContent(event.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder="例如:妈,晚上天气凉,记得添件衣服。晚饭后我给您打电话。"
|
placeholder="例如:妈,晚上天气凉,记得添件衣服。晚饭后我给您打电话。"
|
||||||
className="mt-4 w-full rounded-[24px] border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-4 text-sm leading-7 text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
|
className="field"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", marginTop: 9 }}>
|
||||||
<p className="text-sm leading-7 text-[var(--muted)]">
|
|
||||||
这条话会进入老人的留言列表,也会被 function calling 工具读取到。
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="inline-flex h-12 min-w-32 items-center justify-center rounded-full bg-[var(--ink)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--ink-soft)] disabled:cursor-not-allowed disabled:opacity-60"
|
className="btn primary small"
|
||||||
>
|
>
|
||||||
{submitting ? "正在送达" : "写好并送出"}
|
{submitting ? "正在送达" : "写好并送出"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{notice ? (
|
{notice ? (
|
||||||
<div className="mt-3 space-y-2 text-sm leading-7 text-[var(--muted)]">
|
<div style={{ marginTop: 9, color: "var(--success)", fontSize: 12, lineHeight: 1.6 }}>
|
||||||
<p className="whitespace-pre-line">{notice}</p>
|
<p className="whitespace-pre-line">{notice}</p>
|
||||||
{createdMessagePublicId ? (
|
{createdMessagePublicId ? (
|
||||||
<Link
|
<Link
|
||||||
@@ -223,3 +176,159 @@ export function SendMessageForm({ deviceUuid }: SendMessageFormProps) {
|
|||||||
</form>
|
</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 Link from "next/link";
|
||||||
|
import { ChevronLeft, HeartHandshake, House, ShieldCheck, Stethoscope, UserRound } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { getCaregiverUnreadIncomingCount } from "@/lib/caregiver-panel";
|
||||||
|
|
||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
{ href: "/", label: "总览" },
|
{ href: "/guard", label: "守护", icon: ShieldCheck },
|
||||||
{ href: "/messages", label: "留言" },
|
{ href: "/family", label: "家庭", icon: House },
|
||||||
{ href: "/devices", label: "设备" },
|
{ href: "/care", label: "照护", icon: Stethoscope },
|
||||||
{ href: "/activity", label: "活动" },
|
{ href: "/companion", label: "陪伴", icon: HeartHandshake },
|
||||||
{ href: "/scan", label: "扫码" },
|
{ href: "/settings", label: "我的", icon: UserRound },
|
||||||
];
|
];
|
||||||
|
|
||||||
function isActivePath(currentPath: string, href: string) {
|
function isActivePath(currentPath: string, href: string) {
|
||||||
if (href === "/") {
|
|
||||||
return currentPath === "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
type PanelShellProps = {
|
type PanelShellProps = {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description?: string;
|
||||||
caregiverToken: string;
|
caregiverLabel: string;
|
||||||
|
caregiverId?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
eyebrow?: string;
|
backHref?: string;
|
||||||
|
showTabs?: boolean;
|
||||||
|
unreadCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PanelShell({
|
export async function PanelShell({
|
||||||
currentPath,
|
currentPath,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
caregiverToken,
|
caregiverLabel,
|
||||||
|
caregiverId,
|
||||||
children,
|
children,
|
||||||
actions,
|
actions,
|
||||||
eyebrow = "Digital Human Bridge",
|
backHref,
|
||||||
|
showTabs = !backHref,
|
||||||
|
unreadCount,
|
||||||
}: PanelShellProps) {
|
}: PanelShellProps) {
|
||||||
|
const initial = caregiverLabel.trim().slice(-1) || "安";
|
||||||
|
const resolvedUnreadCount = unreadCount ?? (showTabs && caregiverId
|
||||||
|
? await getCaregiverUnreadIncomingCount(caregiverId)
|
||||||
|
: 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative overflow-hidden px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
|
<main className="app-frame">
|
||||||
<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={`app-content app-stack${backHref ? " detail" : ""}`}>
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
{backHref ? (
|
||||||
<header className="paper-panel relative overflow-hidden rounded-[36px] border border-white/70 px-6 py-7 shadow-[0_35px_90px_rgba(105,68,41,0.13)] sm:px-8 lg:px-10 lg:py-8">
|
<header className="detail-header">
|
||||||
<div className="absolute -right-12 top-[-50px] h-44 w-44 rounded-full bg-[rgba(53,84,141,0.14)] blur-2xl" />
|
<Link href={backHref} className="back-button" aria-label="返回">
|
||||||
<div className="absolute left-10 top-10 h-24 w-24 rounded-full border border-[rgba(118,132,94,0.18)]" />
|
<ChevronLeft size={20} strokeWidth={2} />
|
||||||
|
</Link>
|
||||||
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
|
<h1>{title}</h1>
|
||||||
<div className="max-w-4xl">
|
{actions}
|
||||||
<div className="flex flex-wrap gap-3">
|
</header>
|
||||||
{navigationItems.map((item) => {
|
) : (
|
||||||
const active = isActivePath(currentPath, item.href);
|
<header className="page-heading">
|
||||||
|
<div>
|
||||||
return (
|
<h1>{title}</h1>
|
||||||
<Link
|
{description ? <p>{description}</p> : null}
|
||||||
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}
|
|
||||||
</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>
|
</div>
|
||||||
|
{actions ?? (
|
||||||
{actions ? <div className="w-full max-w-md xl:pt-2">{actions}</div> : null}
|
<Link href="/settings" className="avatar" aria-label="我的">
|
||||||
</div>
|
{initial}
|
||||||
</header>
|
</Link>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showTabs ? (
|
||||||
|
<nav className="bottom-nav" aria-label="主导航">
|
||||||
|
{navigationItems.map((item) => {
|
||||||
|
const active = isActivePath(currentPath, item.href);
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link key={item.href} href={item.href} className={`nav-item${active ? " active" : ""}`}>
|
||||||
|
<Icon size={22} strokeWidth={1.8} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{item.href === "/family" && resolvedUnreadCount > 0 ? <span className="nav-badge">{resolvedUnreadCount}</span> : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
</main>
|
</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";
|
"use client";
|
||||||
|
|
||||||
|
import { Bell, ChevronRight, PenLine, Smartphone } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const PWA_INSTALL_MARKER_KEY = "dh-pwa-installed";
|
||||||
|
|
||||||
type BeforeInstallPromptEvent = Event & {
|
type BeforeInstallPromptEvent = Event & {
|
||||||
prompt: () => Promise<void>;
|
prompt: () => Promise<void>;
|
||||||
userChoice: Promise<{
|
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
|
||||||
outcome: "accepted" | "dismissed";
|
|
||||||
platform: string;
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type NotificationState =
|
type NotificationState = "checking" | "idle" | "pending" | "enabled" | "blocked" | "unsupported";
|
||||||
| "checking"
|
|
||||||
| "idle"
|
|
||||||
| "pending"
|
|
||||||
| "enabled"
|
|
||||||
| "blocked"
|
|
||||||
| "unsupported";
|
|
||||||
|
|
||||||
function urlBase64ToUint8Array(base64String: string) {
|
function urlBase64ToUint8Array(base64String: string) {
|
||||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
const rawData = window.atob(base64);
|
const rawData = window.atob(base64);
|
||||||
const outputArray = new Uint8Array(rawData.length);
|
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;
|
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 vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
|
||||||
const [installPromptEvent, setInstallPromptEvent] =
|
const [installPromptEvent, setInstallPromptEvent] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
useState<BeforeInstallPromptEvent | null>(null);
|
const [isInstalled, setIsInstalled] = useState(false);
|
||||||
const [installing, setInstalling] = 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);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
|
function syncInstallState() {
|
||||||
|
const standalone = isStandaloneDisplayMode();
|
||||||
|
if (standalone) writeInstalledMarker();
|
||||||
|
if (active) setIsInstalled(standalone || readInstalledMarker());
|
||||||
|
}
|
||||||
|
|
||||||
function handleBeforeInstallPrompt(event: Event) {
|
function handleBeforeInstallPrompt(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!active) {
|
if (active) setInstallPromptEvent(event as BeforeInstallPromptEvent);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInstallPromptEvent(event as BeforeInstallPromptEvent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncExistingSubscription(subscription: PushSubscription) {
|
function handleAppInstalled() {
|
||||||
await fetch("/api/push/subscribe", {
|
writeInstalledMarker();
|
||||||
method: "POST",
|
if (active) setIsInstalled(true);
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
subscription: subscription.toJSON(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrapNotifications() {
|
||||||
const standalone =
|
if (!("serviceWorker" in navigator) || !("PushManager" in window) || typeof Notification === "undefined") {
|
||||||
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"
|
|
||||||
) {
|
|
||||||
setNotificationState("unsupported");
|
setNotificationState("unsupported");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -83,87 +85,74 @@ export function PwaControls() {
|
|||||||
try {
|
try {
|
||||||
await navigator.serviceWorker.register("/sw.js");
|
await navigator.serviceWorker.register("/sw.js");
|
||||||
const registration = await navigator.serviceWorker.ready;
|
const registration = await navigator.serviceWorker.ready;
|
||||||
const existingSubscription = await registration.pushManager.getSubscription();
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
if (!active) {
|
if (subscription) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingSubscription) {
|
|
||||||
if (vapidPublicKey) {
|
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");
|
setNotificationState("enabled");
|
||||||
return;
|
} else {
|
||||||
|
setNotificationState(Notification.permission === "denied" ? "blocked" : "idle");
|
||||||
}
|
}
|
||||||
|
|
||||||
setNotificationState(
|
|
||||||
Notification.permission === "denied" ? "blocked" : "idle",
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
if (active) {
|
if (active) setNotificationState("unsupported");
|
||||||
setNotificationState("unsupported");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncInstallState();
|
||||||
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||||
void bootstrap();
|
window.addEventListener("appinstalled", handleAppInstalled);
|
||||||
|
window.addEventListener("focus", syncInstallState);
|
||||||
|
void bootstrapNotifications();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||||
|
window.removeEventListener("appinstalled", handleAppInstalled);
|
||||||
|
window.removeEventListener("focus", syncInstallState);
|
||||||
};
|
};
|
||||||
}, [vapidPublicKey]);
|
}, [vapidPublicKey]);
|
||||||
|
|
||||||
async function enableNotifications() {
|
async function enableNotifications() {
|
||||||
if (!vapidPublicKey) {
|
if (!vapidPublicKey) {
|
||||||
setNotice("当前环境还没配置推送密钥,先把面板安装到桌面即可。\n");
|
setNotice("当前还没有配置推送服务。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setNotificationState("pending");
|
setNotificationState("pending");
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const permission = await Notification.requestPermission();
|
const permission = await Notification.requestPermission();
|
||||||
|
|
||||||
if (permission !== "granted") {
|
if (permission !== "granted") {
|
||||||
setNotificationState(permission === "denied" ? "blocked" : "idle");
|
setNotificationState(permission === "denied" ? "blocked" : "idle");
|
||||||
setNotice(
|
setNotice(permission === "denied" ? "浏览器已拒绝通知权限,请在系统设置中重新开启。" : "这次没有开启通知,稍后仍可再次尝试。");
|
||||||
permission === "denied"
|
|
||||||
? "浏览器已经拒绝消息提醒,请在系统设置里重新打开通知权限。\n"
|
|
||||||
: "这次没有打开通知权限,稍后仍可再次开启。\n",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const registration = await navigator.serviceWorker.ready;
|
const registration = await navigator.serviceWorker.ready;
|
||||||
let subscription = await registration.pushManager.getSubscription();
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
subscription = await registration.pushManager.subscribe({
|
subscription = await registration.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetch("/api/push/subscribe", {
|
await fetch("/api/push/subscribe", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
body: JSON.stringify({ subscription: subscription.toJSON() }),
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
subscription: subscription.toJSON(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setNotificationState("enabled");
|
setNotificationState("enabled");
|
||||||
setNotice("新留言会直接推到这台设备上,点开即可直达对应留言。\n");
|
setNotice("已开启紧急提醒推送。");
|
||||||
} catch {
|
} catch {
|
||||||
setNotificationState("idle");
|
setNotificationState("idle");
|
||||||
setNotice("打开消息提醒失败,请稍后再试。\n");
|
setNotice("打开消息提醒失败,请稍后再试。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,117 +160,89 @@ export function PwaControls() {
|
|||||||
try {
|
try {
|
||||||
const registration = await navigator.serviceWorker.ready;
|
const registration = await navigator.serviceWorker.ready;
|
||||||
const subscription = await registration.pushManager.getSubscription();
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
if (subscription) {
|
if (subscription) {
|
||||||
await fetch("/api/push/unsubscribe", {
|
await fetch("/api/push/unsubscribe", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
endpoint: subscription.endpoint,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
await subscription.unsubscribe();
|
await subscription.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
setNotificationState("idle");
|
setNotificationState("idle");
|
||||||
setNotice("这台设备的消息提醒已关闭。\n");
|
setNotice("这台设备的紧急提醒已关闭。");
|
||||||
} catch {
|
} catch {
|
||||||
setNotice("关闭消息提醒失败,请稍后再试。\n");
|
setNotice("关闭消息提醒失败,请稍后再试。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installApp() {
|
async function installApp() {
|
||||||
|
if (isInstalled) {
|
||||||
|
setNotice("安智伴已经添加到这台设备的主屏幕。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!installPromptEvent) {
|
if (!installPromptEvent) {
|
||||||
setNotice("如果浏览器没有弹出安装按钮,可以从菜单里选择“安装应用”或“添加到主屏幕”。\n");
|
setNotice("请从浏览器菜单中选择“安装应用”或“添加到主屏幕”。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setInstalling(true);
|
setInstalling(true);
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await installPromptEvent.prompt();
|
await installPromptEvent.prompt();
|
||||||
const choice = await installPromptEvent.userChoice;
|
const choice = await installPromptEvent.userChoice;
|
||||||
|
|
||||||
if (choice.outcome === "accepted") {
|
if (choice.outcome === "accepted") {
|
||||||
|
writeInstalledMarker();
|
||||||
setInstallPromptEvent(null);
|
setInstallPromptEvent(null);
|
||||||
setNotice("安装提示已经确认,完成后就能像应用一样直接打开。\n");
|
setIsInstalled(true);
|
||||||
} else {
|
setNotice("已确认添加到主屏幕。");
|
||||||
setNotice("这次先关闭了安装提示,之后仍可从浏览器菜单安装。\n");
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setInstalling(false);
|
setInstalling(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pushOn = notificationState === "enabled";
|
||||||
|
const toggleDisabled = notificationState === "checking" || notificationState === "pending" || notificationState === "unsupported";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_22px_55px_rgba(53,84,141,0.12)]">
|
<section className="card">
|
||||||
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
<div className="settings-row">
|
||||||
安装与提醒
|
<span className="settings-icon"><Bell size={17} strokeWidth={1.8} /></span>
|
||||||
</p>
|
<span className="row-main">
|
||||||
<h2 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
<span className="row-title">紧急提醒推送</span>
|
||||||
把家属面板装到桌面,并接收新留言提醒
|
<span className="row-meta">红色风险提醒将第一时间通知本机</span>
|
||||||
</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>
|
||||||
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
|
|
||||||
{notificationState === "enabled"
|
|
||||||
? "消息提醒已开启"
|
|
||||||
: notificationState === "blocked"
|
|
||||||
? "通知权限已被拒绝"
|
|
||||||
: "消息提醒未开启"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 flex flex-wrap gap-3">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
className={`toggle${pushOn ? " on" : ""}`}
|
||||||
void installApp();
|
disabled={toggleDisabled}
|
||||||
}}
|
onClick={() => void (pushOn ? disableNotifications() : enableNotifications())}
|
||||||
disabled={installing}
|
aria-label={pushOn ? "关闭紧急提醒推送" : "开启紧急提醒推送"}
|
||||||
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"
|
aria-pressed={pushOn}
|
||||||
>
|
>
|
||||||
{installing ? "正在呼起安装" : "安装到桌面"}
|
<span className="toggle-knob" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{notice ? (
|
<Link className="settings-row" href="/devices">
|
||||||
<p className="mt-4 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
|
<span className="settings-icon olive"><Smartphone size={17} strokeWidth={1.8} /></span>
|
||||||
{notice}
|
<span className="row-main">
|
||||||
</p>
|
<span className="row-title">设备管理</span>
|
||||||
) : null}
|
<span className="row-meta">{deviceCount > 0 ? `已连接 ${deviceCount} 台长辈设备` : "绑定长辈的安智伴设备"}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="chevron" size={16} strokeWidth={2} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button type="button" className="settings-row" onClick={() => void installApp()} disabled={installing}>
|
||||||
|
<span className="settings-icon muted"><PenLine size={17} strokeWidth={1.8} /></span>
|
||||||
|
<span className="row-main">
|
||||||
|
<span className="row-title">添加到主屏幕</span>
|
||||||
|
<span className="row-meta">{isInstalled ? "已添加,可像 App 一样直接打开" : installing ? "正在呼起安装" : "像 App 一样从桌面直接打开"}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="chevron" size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{notice ? <p className="form-notice" style={{ margin: 0, padding: "0 16px 13px" }}>{notice}</p> : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
|
import { useNavigationProgress } from "@/components/navigation-progress";
|
||||||
|
|
||||||
function extractDeviceUuid(rawValue: string) {
|
function extractDeviceUuid(rawValue: string) {
|
||||||
const trimmedValue = rawValue.trim();
|
const trimmedValue = rawValue.trim();
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ function extractDeviceUuid(rawValue: string) {
|
|||||||
|
|
||||||
export function ScanClient() {
|
export function ScanClient() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { beginNavigationProgress } = useNavigationProgress();
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const scannerRef = useRef<QrScanner | null>(null);
|
const scannerRef = useRef<QrScanner | null>(null);
|
||||||
const [statusText, setStatusText] = useState("正在准备镜头……");
|
const [statusText, setStatusText] = useState("正在准备镜头……");
|
||||||
@@ -45,38 +48,14 @@ export function ScanClient() {
|
|||||||
|
|
||||||
scannerRef.current.stop();
|
scannerRef.current.stop();
|
||||||
setErrorText(null);
|
setErrorText(null);
|
||||||
setStatusText("识别到了设备码,正在建立连接……");
|
setStatusText("识别到了设备,正在打开绑定页面……");
|
||||||
|
|
||||||
try {
|
startTransition(() => {
|
||||||
const response = await fetch("/api/family/bind", {
|
beginNavigationProgress();
|
||||||
method: "POST",
|
router.replace(
|
||||||
headers: {
|
`/bind?deviceUuid=${encodeURIComponent(scannedValue)}&source=scan`,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ deviceUuid: scannedValue }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = (await response.json().catch(() => null)) as
|
|
||||||
| { error?: string; deviceUuid?: string }
|
|
||||||
| null;
|
|
||||||
const deviceUuid = payload?.deviceUuid;
|
|
||||||
|
|
||||||
if (!response.ok || !deviceUuid) {
|
|
||||||
throw new Error(payload?.error || "绑定失败,请重新扫描一次。");
|
|
||||||
}
|
|
||||||
|
|
||||||
startTransition(() => {
|
|
||||||
router.replace(
|
|
||||||
`/bind?deviceUuid=${encodeURIComponent(deviceUuid)}&source=scan`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setErrorText(
|
|
||||||
error instanceof Error ? error.message : "绑定失败,请重新扫描一次。",
|
|
||||||
);
|
);
|
||||||
setStatusText("请再把二维码对准镜头中央。");
|
});
|
||||||
await scannerRef.current.start().catch(() => null);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,7 +70,7 @@ export function ScanClient() {
|
|||||||
const hasCamera = await QrScanner.hasCamera();
|
const hasCamera = await QrScanner.hasCamera();
|
||||||
|
|
||||||
if (!hasCamera) {
|
if (!hasCamera) {
|
||||||
setErrorText("当前设备没有可用摄像头,请改用系统扫码或手动粘贴设备码。");
|
setErrorText("当前设备没有可用摄像头,请改用手动粘贴设备码。");
|
||||||
setStatusText("无法打开镜头");
|
setStatusText("无法打开镜头");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -114,7 +93,7 @@ export function ScanClient() {
|
|||||||
await scanner.start();
|
await scanner.start();
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setStatusText("请把老人的二维码放到取景框中央。");
|
setStatusText("请将长辈手机上的二维码放到取景框内。");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorText(
|
setErrorText(
|
||||||
@@ -136,8 +115,18 @@ export function ScanClient() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[32px] border border-white/70 bg-white/82 p-5 shadow-[0_30px_70px_rgba(115,72,44,0.14)] backdrop-blur">
|
<div className="rounded-[32px] border border-white/75 bg-white/82 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)]">
|
||||||
<div className="relative overflow-hidden rounded-[28px] border border-[var(--line)] bg-[var(--ink)]/95">
|
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
|
||||||
|
扫码画面
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 font-[family-name:var(--font-display)] text-3xl text-[var(--ink)]">
|
||||||
|
将二维码放入取景框
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">
|
||||||
|
保持画面稳定一两秒,识别成功后会自动继续,不需要额外点击。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="relative mt-5 overflow-hidden rounded-[28px] border border-[var(--line)] bg-[var(--ink)]/95">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
muted
|
muted
|
||||||
@@ -145,13 +134,12 @@ export function ScanClient() {
|
|||||||
className="aspect-[3/4] w-full object-cover"
|
className="aspect-[3/4] w-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0_28%,rgba(10,7,5,0.48)_29%_100%)]" />
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0_28%,rgba(10,7,5,0.48)_29%_100%)]" />
|
||||||
<div className="pointer-events-none absolute inset-x-[16%] top-[20%] aspect-square rounded-[28px] border-2 border-white/85 shadow-[0_0_0_999px_rgba(0,0,0,0.18)]" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
<p className="text-base font-semibold text-[var(--ink)]">{statusText}</p>
|
<p className="text-base font-semibold text-[var(--ink)]">{statusText}</p>
|
||||||
<p className="text-sm leading-7 text-[var(--muted)]">
|
<p className="text-sm leading-7 text-[var(--muted)]">
|
||||||
扫到后会直接把这位老人绑定到当前家属账号,不需要额外输密码。
|
扫描成功后会自动连接长辈的设备,无需额外操作。
|
||||||
</p>
|
</p>
|
||||||
{errorText ? (
|
{errorText ? (
|
||||||
<p className="rounded-[20px] bg-[var(--paper-soft)] px-4 py-3 text-sm leading-7 text-[var(--copper-deep)]">
|
<p className="rounded-[20px] bg-[var(--paper-soft)] px-4 py-3 text-sm leading-7 text-[var(--copper-deep)]">
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const eslintConfig = defineConfig([
|
|||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
// Design reference exported by the prototyping tool; not application source.
|
||||||
|
"安智伴家属端界面优化/**",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,61 @@ const deviceSummarySelect = {
|
|||||||
lastSeenAt: true,
|
lastSeenAt: true,
|
||||||
} as const;
|
} 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) {
|
export async function getCaregiverOverview(caregiverId: string) {
|
||||||
const dashboard = await getCaregiverDashboard(caregiverId);
|
const dashboard = await getCaregiverDashboard(caregiverId);
|
||||||
|
|
||||||
@@ -36,6 +91,11 @@ export async function getCaregiverOverview(caregiverId: string) {
|
|||||||
where: {
|
where: {
|
||||||
direction: MessageDirection.FAMILY_TO_ELDER,
|
direction: MessageDirection.FAMILY_TO_ELDER,
|
||||||
caregiverId,
|
caregiverId,
|
||||||
|
elderDevice: {
|
||||||
|
bindings: {
|
||||||
|
some: { caregiverId },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
elderDevice: {
|
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 {
|
return {
|
||||||
dashboard,
|
dashboard,
|
||||||
recentIncomingMessages,
|
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) {
|
export async function getCaregiverMessages(caregiverId: string) {
|
||||||
const [messages, unreadIncomingCount, unreadOutgoingCount] = await prisma.$transaction([
|
const [messages, unreadIncomingCount, unreadOutgoingCount] = await prisma.$transaction([
|
||||||
prisma.familyMessage.findMany({
|
prisma.familyMessage.findMany({
|
||||||
@@ -72,9 +155,11 @@ export async function getCaregiverMessages(caregiverId: string) {
|
|||||||
orderBy: [{ createdAt: "desc" }, { publicId: "desc" }],
|
orderBy: [{ createdAt: "desc" }, { publicId: "desc" }],
|
||||||
take: 120,
|
take: 120,
|
||||||
}),
|
}),
|
||||||
|
getCaregiverUnreadIncomingCount(caregiverId),
|
||||||
prisma.familyMessage.count({
|
prisma.familyMessage.count({
|
||||||
where: {
|
where: {
|
||||||
direction: MessageDirection.ELDER_TO_FAMILY,
|
direction: MessageDirection.FAMILY_TO_ELDER,
|
||||||
|
caregiverId,
|
||||||
elderDevice: {
|
elderDevice: {
|
||||||
bindings: {
|
bindings: {
|
||||||
some: { caregiverId },
|
some: { caregiverId },
|
||||||
@@ -83,15 +168,15 @@ export async function getCaregiverMessages(caregiverId: string) {
|
|||||||
readAt: null,
|
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 {
|
return {
|
||||||
messages,
|
messages,
|
||||||
unreadIncomingCount,
|
unreadIncomingCount,
|
||||||
@@ -140,6 +225,9 @@ export async function getCaregiverMessageDetail(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deviceNameMap = await loadCaregiverDeviceNameMap(caregiverId, [message.elderDeviceId]);
|
||||||
|
applyCaregiverDeviceNames([message], deviceNameMap);
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +303,11 @@ export async function getCaregiverDeviceDetail(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...binding,
|
...binding,
|
||||||
|
elderDevice: {
|
||||||
|
...binding.elderDevice,
|
||||||
|
displayName:
|
||||||
|
binding.alias?.trim() || binding.elderDevice.displayName?.trim() || null,
|
||||||
|
},
|
||||||
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
|
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
|
||||||
familyUnreadCount,
|
familyUnreadCount,
|
||||||
elderUnreadCount,
|
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 {
|
return {
|
||||||
usageEvents,
|
usageEvents,
|
||||||
conversationTurns,
|
conversationTurns,
|
||||||
toolCalls,
|
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 = (
|
const PUBLIC_BASE_URL = (
|
||||||
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
|
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
|
||||||
).replace(/\/+$/, "");
|
).replace(/\/+$/, "");
|
||||||
|
const DEVICE_ALIAS_MAX_LENGTH = 24;
|
||||||
|
|
||||||
const DEVICE_UUID_PATTERN =
|
const DEVICE_UUID_PATTERN =
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
@@ -90,6 +91,24 @@ export function extractDeviceUuid(rawValue: string) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceAlias(rawAlias?: string | null) {
|
||||||
|
const alias = rawAlias?.trim() || null;
|
||||||
|
|
||||||
|
if (!alias) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alias.length > DEVICE_ALIAS_MAX_LENGTH) {
|
||||||
|
throw new Error(`设备别名请控制在 ${DEVICE_ALIAS_MAX_LENGTH} 个字以内。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return alias;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBindingDisplayName(alias?: string | null, displayName?: string | null) {
|
||||||
|
return alias?.trim() || displayName?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureDeviceRegistration(input: RegisterDeviceInput) {
|
export async function ensureDeviceRegistration(input: RegisterDeviceInput) {
|
||||||
const deviceUuid = normalizeDeviceUuid(input.deviceUuid);
|
const deviceUuid = normalizeDeviceUuid(input.deviceUuid);
|
||||||
|
|
||||||
@@ -121,6 +140,8 @@ export async function bindDeviceToCaregiver(
|
|||||||
|
|
||||||
const device = await ensureDeviceRegistration({ deviceUuid });
|
const device = await ensureDeviceRegistration({ deviceUuid });
|
||||||
|
|
||||||
|
await migrateLegacyCaregiverDataForDevice(caregiverId, device.id);
|
||||||
|
|
||||||
await prisma.deviceBinding.upsert({
|
await prisma.deviceBinding.upsert({
|
||||||
where: {
|
where: {
|
||||||
caregiverId_elderDeviceId: {
|
caregiverId_elderDeviceId: {
|
||||||
@@ -138,6 +159,104 @@ export async function bindDeviceToCaregiver(
|
|||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function migrateLegacyCaregiverDataForDevice(
|
||||||
|
caregiverId: string,
|
||||||
|
elderDeviceId: string,
|
||||||
|
) {
|
||||||
|
const legacyCaregivers = await prisma.caregiverAccount.findMany({
|
||||||
|
where: {
|
||||||
|
id: { not: caregiverId },
|
||||||
|
username: null,
|
||||||
|
bindings: {
|
||||||
|
some: {
|
||||||
|
elderDeviceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
take: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (legacyCaregivers.length !== 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyCaregiverId = legacyCaregivers[0]?.id;
|
||||||
|
|
||||||
|
if (!legacyCaregiverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const legacyBindings = await tx.deviceBinding.findMany({
|
||||||
|
where: {
|
||||||
|
caregiverId: legacyCaregiverId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
elderDeviceId: true,
|
||||||
|
alias: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
legacyBindings.map((binding) =>
|
||||||
|
tx.deviceBinding.upsert({
|
||||||
|
where: {
|
||||||
|
caregiverId_elderDeviceId: {
|
||||||
|
caregiverId,
|
||||||
|
elderDeviceId: binding.elderDeviceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: binding.alias ? { alias: binding.alias } : {},
|
||||||
|
create: {
|
||||||
|
caregiverId,
|
||||||
|
elderDeviceId: binding.elderDeviceId,
|
||||||
|
alias: binding.alias,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx.familyMessage.updateMany({
|
||||||
|
where: {
|
||||||
|
caregiverId: legacyCaregiverId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
caregiverId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.pushSubscription.updateMany({
|
||||||
|
where: {
|
||||||
|
caregiverId: legacyCaregiverId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
caregiverId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.deviceBinding.deleteMany({
|
||||||
|
where: {
|
||||||
|
caregiverId: legacyCaregiverId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.caregiverSession.deleteMany({
|
||||||
|
where: {
|
||||||
|
caregiverId: legacyCaregiverId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.caregiverAccount.delete({
|
||||||
|
where: {
|
||||||
|
id: legacyCaregiverId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function parseImportance(rawImportance?: string | null) {
|
function parseImportance(rawImportance?: string | null) {
|
||||||
const normalizedImportance = rawImportance?.trim().toUpperCase();
|
const normalizedImportance = rawImportance?.trim().toUpperCase();
|
||||||
|
|
||||||
@@ -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) {
|
export async function recordUsageEvent(input: UsageEventInput) {
|
||||||
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
|
const device = await ensureDeviceRegistration({ deviceUuid: input.deviceUuid });
|
||||||
|
|
||||||
@@ -396,6 +585,21 @@ export async function recordToolCall(input: ToolCallInput) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createdMessagePublicId = createdMessage.publicId;
|
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 {
|
return {
|
||||||
@@ -493,6 +697,13 @@ export async function getCaregiverDashboard(caregiverId: string) {
|
|||||||
caregiver,
|
caregiver,
|
||||||
devices: caregiver.bindings.map((binding) => ({
|
devices: caregiver.bindings.map((binding) => ({
|
||||||
...binding,
|
...binding,
|
||||||
|
elderDevice: {
|
||||||
|
...binding.elderDevice,
|
||||||
|
displayName: resolveBindingDisplayName(
|
||||||
|
binding.alias,
|
||||||
|
binding.elderDevice.displayName,
|
||||||
|
),
|
||||||
|
},
|
||||||
familyUnreadCount: familyUnreadMap.get(binding.elderDeviceId) || 0,
|
familyUnreadCount: familyUnreadMap.get(binding.elderDeviceId) || 0,
|
||||||
elderUnreadCount: elderUnreadMap.get(binding.elderDeviceId) || 0,
|
elderUnreadCount: elderUnreadMap.get(binding.elderDeviceId) || 0,
|
||||||
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
|
bindUrl: createBindUrl(binding.elderDevice.deviceUuid),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildCaregiverSessionBootstrapPath,
|
buildCaregiverLoginPath,
|
||||||
getCaregiverSession,
|
getCaregiverSession,
|
||||||
} from "@/lib/session";
|
} from "@/lib/session";
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ export async function requireCaregiverSession(redirectTo: string) {
|
|||||||
const caregiver = await getCaregiverSession();
|
const caregiver = await getCaregiverSession();
|
||||||
|
|
||||||
if (!caregiver) {
|
if (!caregiver) {
|
||||||
redirect(buildCaregiverSessionBootstrapPath(redirectTo));
|
redirect(buildCaregiverLoginPath(redirectTo));
|
||||||
}
|
}
|
||||||
|
|
||||||
return caregiver;
|
return caregiver;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function getUsageLabel(eventType: UsageEventType | string) {
|
|||||||
case UsageEventType.APP_OPEN:
|
case UsageEventType.APP_OPEN:
|
||||||
return "打开了老人端应用";
|
return "打开了老人端应用";
|
||||||
case UsageEventType.SETTINGS_OPENED:
|
case UsageEventType.SETTINGS_OPENED:
|
||||||
return "打开了家人连接页";
|
return "查看了设置页面";
|
||||||
case UsageEventType.AI_SESSION_STARTED:
|
case UsageEventType.AI_SESSION_STARTED:
|
||||||
return "开始了一次陪伴对话";
|
return "开始了一次陪伴对话";
|
||||||
case UsageEventType.AI_SESSION_ENDED:
|
case UsageEventType.AI_SESSION_ENDED:
|
||||||
@@ -49,7 +49,7 @@ export function getUsageLabel(eventType: UsageEventType | string) {
|
|||||||
case UsageEventType.CAMERA_DISABLED:
|
case UsageEventType.CAMERA_DISABLED:
|
||||||
return "关闭了镜头模式";
|
return "关闭了镜头模式";
|
||||||
case UsageEventType.TOOL_CALLED:
|
case UsageEventType.TOOL_CALLED:
|
||||||
return "模型调用了一次桥接工具";
|
return "使用了一项智能服务";
|
||||||
default:
|
default:
|
||||||
return eventType;
|
return eventType;
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ export function getConversationRoleLabel(role: ConversationRole | string) {
|
|||||||
case ConversationRole.USER:
|
case ConversationRole.USER:
|
||||||
return "老人说";
|
return "老人说";
|
||||||
case ConversationRole.TOOL:
|
case ConversationRole.TOOL:
|
||||||
return "工具结果";
|
return "智能助理";
|
||||||
default:
|
default:
|
||||||
return "数字人回复";
|
return "数字人回复";
|
||||||
}
|
}
|
||||||
@@ -109,6 +109,9 @@ export function getMessageHref(publicId: number) {
|
|||||||
return `/messages/${publicId}`;
|
return `/messages/${publicId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDeviceName(displayName?: string | null) {
|
export function getDeviceName(
|
||||||
return displayName?.trim() || "未命名陪伴设备";
|
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 = (
|
const APP_BASE_URL = (
|
||||||
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
|
process.env.NEXT_PUBLIC_APP_URL || "https://digital-human.xn--876a.net"
|
||||||
).replace(/\/+$/, "");
|
).replace(/\/+$/, "");
|
||||||
@@ -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) {
|
export async function sendIncomingMessagePush(publicId: number) {
|
||||||
if (!hasPushConfiguration()) {
|
if (!hasPushConfiguration()) {
|
||||||
return;
|
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) {
|
if (subscriptions.length === 0) {
|
||||||
@@ -162,47 +360,99 @@ export async function sendIncomingMessagePush(publicId: number) {
|
|||||||
|
|
||||||
ensureVapidDetails();
|
ensureVapidDetails();
|
||||||
|
|
||||||
const deviceName = getDeviceName(message.elderDevice.displayName);
|
await Promise.all(
|
||||||
const payload = JSON.stringify({
|
|
||||||
title: `${deviceName} 发来一条新留言`,
|
|
||||||
body: truncateText(message.content, 72),
|
|
||||||
url: absoluteUrl(`/messages/${message.publicId}`),
|
|
||||||
icon: absoluteUrl("/pwa/icon-192x192.png"),
|
|
||||||
badge: absoluteUrl("/pwa/badge-96x96.png"),
|
|
||||||
tag: `message-${message.publicId}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.allSettled(
|
|
||||||
subscriptions.map(async (subscription) => {
|
subscriptions.map(async (subscription) => {
|
||||||
try {
|
const deviceName = getDeviceName(
|
||||||
await webpush.sendNotification(
|
subscription.caregiver.bindings[0]?.alias,
|
||||||
{
|
message.elderDevice.displayName,
|
||||||
endpoint: subscription.endpoint,
|
);
|
||||||
expirationTime: subscription.expirationTime?.getTime() || null,
|
|
||||||
keys: {
|
|
||||||
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) {
|
const payload = JSON.stringify({
|
||||||
await prisma.pushSubscription.deleteMany({
|
title: `${deviceName} 发来一条新留言`,
|
||||||
where: { endpoint: subscription.endpoint },
|
body: truncateText(message.content, 72),
|
||||||
});
|
url: absoluteUrl(`/messages/${message.publicId}`),
|
||||||
}
|
icon: absoluteUrl("/pwa/icon-192x192.png"),
|
||||||
}
|
badge: absoluteUrl("/pwa/badge-96x96.png"),
|
||||||
|
tag: `message-${message.publicId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendPushNotification({
|
||||||
|
subscription: {
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
expirationTime: subscription.expirationTime,
|
||||||
|
p256dh: subscription.p256dh,
|
||||||
|
auth: subscription.auth,
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
topic: `msg-${message.publicId}`,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendCaregiverTestPush(input: {
|
||||||
|
caregiverId: string;
|
||||||
|
caregiverLabel: string;
|
||||||
|
}) {
|
||||||
|
if (!hasPushConfiguration()) {
|
||||||
|
throw new Error("当前环境还没有配置推送服务。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptions = await prisma.pushSubscription.findMany({
|
||||||
|
where: {
|
||||||
|
caregiverId: input.caregiverId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
endpoint: true,
|
||||||
|
expirationTime: true,
|
||||||
|
p256dh: true,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: "安智伴推送测试",
|
||||||
|
body: `${input.caregiverLabel} 账号下的通知链路工作正常。`,
|
||||||
|
url: absoluteUrl("/settings"),
|
||||||
|
icon: absoluteUrl("/pwa/icon-192x192.png"),
|
||||||
|
badge: absoluteUrl("/pwa/badge-96x96.png"),
|
||||||
|
tag: `push-test-${input.caregiverId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return deliverPushToSubscriptions({
|
||||||
|
subscriptions,
|
||||||
|
payload,
|
||||||
|
topic: `test-${input.caregiverId.slice(0, 20)}`,
|
||||||
|
collectFailureDetails: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendGuardAlertPush(alertId: string) {
|
||||||
|
if (!hasPushConfiguration()) return;
|
||||||
|
const alert = await prisma.safetyAlert.findUnique({
|
||||||
|
where: { id: alertId },
|
||||||
|
include: {
|
||||||
|
familyCircle: {
|
||||||
|
include: {
|
||||||
|
elderProfile: { include: { elderDevice: true } },
|
||||||
|
memberships: {
|
||||||
|
where: { status: "ACTIVE", emergencyPush: true },
|
||||||
|
include: { caregiver: { include: { pushSubscriptions: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!alert) return;
|
||||||
|
const subscriptions = alert.familyCircle.memberships.flatMap((member) => member.caregiver.pushSubscriptions);
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: `${alert.level === "RED" ? "紧急关注" : "守护提醒"} · ${alert.familyCircle.elderProfile.preferredName}`,
|
||||||
|
body: truncateText(alert.summary, 72),
|
||||||
|
url: absoluteUrl(`/guard#alert-${alert.id}`),
|
||||||
|
icon: absoluteUrl("/pwa/icon-192x192.png"),
|
||||||
|
badge: absoluteUrl("/pwa/badge-96x96.png"),
|
||||||
|
tag: `guard-alert-${alert.id}`,
|
||||||
|
});
|
||||||
|
await deliverPushToSubscriptions({ subscriptions, payload, topic: `guard-${alert.id}` });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { compare, hash } from "bcryptjs";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
@@ -6,6 +7,8 @@ import { prisma } from "@/lib/prisma";
|
|||||||
export const CAREGIVER_SESSION_COOKIE = "dh-caregiver-session";
|
export const CAREGIVER_SESSION_COOKIE = "dh-caregiver-session";
|
||||||
|
|
||||||
const CAREGIVER_SESSION_MAX_AGE = 60 * 60 * 24 * 365;
|
const CAREGIVER_SESSION_MAX_AGE = 60 * 60 * 24 * 365;
|
||||||
|
const CAREGIVER_USERNAME_PATTERN = /^[a-z0-9][a-z0-9_-]{2,23}$/;
|
||||||
|
const CAREGIVER_PASSWORD_MIN_LENGTH = 8;
|
||||||
|
|
||||||
function createSessionToken() {
|
function createSessionToken() {
|
||||||
return randomUUID().replaceAll("-", "");
|
return randomUUID().replaceAll("-", "");
|
||||||
@@ -29,44 +32,170 @@ function normalizeRedirectPath(redirectTo?: string) {
|
|||||||
return redirectTo;
|
return redirectTo;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertCaregiverByToken(sessionToken: string) {
|
function normalizeCaregiverUsername(rawValue: string) {
|
||||||
return prisma.caregiverAccount.upsert({
|
return rawValue.trim().toLowerCase();
|
||||||
where: { sessionToken },
|
|
||||||
update: {},
|
|
||||||
create: { sessionToken },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCaregiverSessionBootstrapPath(redirectTo: string) {
|
function parseCaregiverUsername(rawValue: string) {
|
||||||
|
const username = normalizeCaregiverUsername(rawValue);
|
||||||
|
|
||||||
|
if (!CAREGIVER_USERNAME_PATTERN.test(username)) {
|
||||||
|
throw new Error("用户名请使用 3 到 24 位字母、数字、下划线或横线。");
|
||||||
|
}
|
||||||
|
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCaregiverPassword(rawValue: string) {
|
||||||
|
if (rawValue.length < CAREGIVER_PASSWORD_MIN_LENGTH) {
|
||||||
|
throw new Error(`密码至少需要 ${CAREGIVER_PASSWORD_MIN_LENGTH} 位。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawValue.length > 72) {
|
||||||
|
throw new Error("密码请控制在 72 位以内。");
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentSessionToken() {
|
||||||
|
return (await cookies()).get(CAREGIVER_SESSION_COOKIE)?.value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCaregiverSession(input: {
|
||||||
|
caregiverId: string;
|
||||||
|
userAgent?: string | null;
|
||||||
|
}) {
|
||||||
|
const currentSessionToken = await getCurrentSessionToken();
|
||||||
|
|
||||||
|
if (currentSessionToken) {
|
||||||
|
await prisma.caregiverSession.deleteMany({
|
||||||
|
where: {
|
||||||
|
sessionToken: currentSessionToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = createSessionToken();
|
||||||
|
|
||||||
|
await prisma.caregiverSession.create({
|
||||||
|
data: {
|
||||||
|
caregiverId: input.caregiverId,
|
||||||
|
sessionToken,
|
||||||
|
userAgent: input.userAgent?.trim() || null,
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(await cookies()).set(CAREGIVER_SESSION_COOKIE, sessionToken, getCookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCaregiverLoginPath(redirectTo: string) {
|
||||||
const normalizedRedirect = normalizeRedirectPath(redirectTo);
|
const normalizedRedirect = normalizeRedirectPath(redirectTo);
|
||||||
return `/api/family/session?redirectTo=${encodeURIComponent(normalizedRedirect)}`;
|
return `/login?redirectTo=${encodeURIComponent(normalizedRedirect)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCaregiverDisplayName(input: {
|
||||||
|
username?: string | null;
|
||||||
|
nickname?: string | null;
|
||||||
|
}) {
|
||||||
|
return input.nickname?.trim() || input.username?.trim() || "临时账号";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeCaregiverRedirectPath(redirectTo?: string | null) {
|
||||||
|
return normalizeRedirectPath(redirectTo || undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCaregiverSession() {
|
export async function getCaregiverSession() {
|
||||||
const sessionToken = (await cookies()).get(CAREGIVER_SESSION_COOKIE)?.value;
|
const sessionToken = await getCurrentSessionToken();
|
||||||
|
|
||||||
if (!sessionToken) {
|
if (!sessionToken) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return upsertCaregiverByToken(sessionToken);
|
const session = await prisma.caregiverSession.findUnique({
|
||||||
}
|
where: { sessionToken },
|
||||||
|
include: {
|
||||||
|
caregiver: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export async function getOrCreateCaregiverSession() {
|
if (!session) {
|
||||||
const cookieStore = await cookies();
|
return null;
|
||||||
let sessionToken = cookieStore.get(CAREGIVER_SESSION_COOKIE)?.value;
|
|
||||||
|
|
||||||
if (!sessionToken) {
|
|
||||||
sessionToken = createSessionToken();
|
|
||||||
cookieStore.set(CAREGIVER_SESSION_COOKIE, sessionToken, getCookieOptions());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return upsertCaregiverByToken(sessionToken);
|
return session.caregiver;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCaregiverSessionCookieValue() {
|
export async function registerCaregiverAccount(input: {
|
||||||
return {
|
username: string;
|
||||||
sessionToken: createSessionToken(),
|
password: string;
|
||||||
options: getCookieOptions(),
|
userAgent?: string | null;
|
||||||
};
|
}) {
|
||||||
|
const username = parseCaregiverUsername(input.username);
|
||||||
|
const password = parseCaregiverPassword(input.password);
|
||||||
|
const existingCaregiver = await prisma.caregiverAccount.findUnique({
|
||||||
|
where: { username },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCaregiver) {
|
||||||
|
throw new Error("这个用户名已经被注册了,请直接登录。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const caregiver = await prisma.caregiverAccount.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
passwordHash: await hash(password, 10),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createCaregiverSession({
|
||||||
|
caregiverId: caregiver.id,
|
||||||
|
userAgent: input.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return caregiver;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginCaregiverAccount(input: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
userAgent?: string | null;
|
||||||
|
}) {
|
||||||
|
const username = parseCaregiverUsername(input.username);
|
||||||
|
const password = parseCaregiverPassword(input.password);
|
||||||
|
const caregiver = await prisma.caregiverAccount.findUnique({
|
||||||
|
where: { username },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!caregiver?.passwordHash) {
|
||||||
|
throw new Error("用户名或密码不正确。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordMatched = await compare(password, caregiver.passwordHash);
|
||||||
|
|
||||||
|
if (!passwordMatched) {
|
||||||
|
throw new Error("用户名或密码不正确。");
|
||||||
|
}
|
||||||
|
|
||||||
|
await createCaregiverSession({
|
||||||
|
caregiverId: caregiver.id,
|
||||||
|
userAgent: input.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return caregiver;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearCurrentCaregiverSession() {
|
||||||
|
const sessionToken = await getCurrentSessionToken();
|
||||||
|
|
||||||
|
if (sessionToken) {
|
||||||
|
await prisma.caregiverSession.deleteMany({
|
||||||
|
where: {
|
||||||
|
sessionToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(await cookies()).delete(CAREGIVER_SESSION_COOKIE);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
turbopack: {
|
||||||
|
root: process.cwd(),
|
||||||
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,12 +12,15 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.7.0",
|
"@prisma/adapter-pg": "^7.7.0",
|
||||||
"@prisma/client": "^7.7.0",
|
"@prisma/client": "^7.7.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"next": "16.2.4",
|
"lucide-react": "^1.8.0",
|
||||||
|
"next": "^16.2.10",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"web-push": "^3.6.7"
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
@@ -33,6 +36,10 @@
|
|||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
|
||||||
|
"lightningcss-darwin-arm64": "1.32.0"
|
||||||
|
},
|
||||||
"ignoreScripts": [
|
"ignoreScripts": [
|
||||||
"sharp",
|
"sharp",
|
||||||
"unrs-resolver"
|
"unrs-resolver"
|
||||||
|
|||||||
@@ -0,0 +1,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
|
FAILURE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum FamilyRole {
|
||||||
|
ADMIN
|
||||||
|
CAREGIVER
|
||||||
|
CARING
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MembershipStatus {
|
||||||
|
ACTIVE
|
||||||
|
REMOVED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InvitationStatus {
|
||||||
|
PENDING
|
||||||
|
ACCEPTED
|
||||||
|
EXPIRED
|
||||||
|
REVOKED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ContentVisibility {
|
||||||
|
ELDER_ONLY
|
||||||
|
FAMILY_ONLY
|
||||||
|
FAMILY_AND_ELDER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MemoPriority {
|
||||||
|
NORMAL
|
||||||
|
IMPORTANT
|
||||||
|
PINNED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CareTaskStatus {
|
||||||
|
OPEN
|
||||||
|
CLAIMED
|
||||||
|
COMPLETED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MedicationSource {
|
||||||
|
PRESCRIPTION
|
||||||
|
PACKAGE_LABEL
|
||||||
|
FAMILY_ENTRY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MedicationDoseStatus {
|
||||||
|
SCHEDULED
|
||||||
|
REMINDING
|
||||||
|
TAKEN
|
||||||
|
SNOOZED
|
||||||
|
SKIPPED
|
||||||
|
MISSED
|
||||||
|
NEEDS_FAMILY_CONFIRMATION
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CheckInStatus {
|
||||||
|
PENDING
|
||||||
|
SAFE
|
||||||
|
NEEDS_CONTACT
|
||||||
|
DECLINED
|
||||||
|
EXPIRED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AlertLevel {
|
||||||
|
RED
|
||||||
|
ORANGE
|
||||||
|
YELLOW
|
||||||
|
BLUE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AlertStatus {
|
||||||
|
OPEN
|
||||||
|
ACKNOWLEDGED
|
||||||
|
RESOLVED
|
||||||
|
DISMISSED
|
||||||
|
}
|
||||||
|
|
||||||
model CaregiverAccount {
|
model CaregiverAccount {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
sessionToken String @unique
|
username String? @unique
|
||||||
nickname String?
|
passwordHash String?
|
||||||
createdAt DateTime @default(now())
|
nickname String?
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
bindings DeviceBinding[]
|
updatedAt DateTime @updatedAt
|
||||||
messages FamilyMessage[]
|
sessions CaregiverSession[]
|
||||||
pushSubscriptions PushSubscription[]
|
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 {
|
model ElderDevice {
|
||||||
@@ -69,12 +170,264 @@ model ElderDevice {
|
|||||||
conversationTurns ConversationTurn[]
|
conversationTurns ConversationTurn[]
|
||||||
usageEvents UsageEvent[]
|
usageEvents UsageEvent[]
|
||||||
toolCalls ToolCallLog[]
|
toolCalls ToolCallLog[]
|
||||||
|
deviceTokenHash String?
|
||||||
|
elderProfile ElderProfile?
|
||||||
|
}
|
||||||
|
|
||||||
|
model ElderProfile {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
elderDeviceId String @unique
|
||||||
|
preferredName String
|
||||||
|
timezone String @default("Asia/Shanghai")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
elderDevice ElderDevice @relation(fields: [elderDeviceId], references: [id], onDelete: Cascade)
|
||||||
|
familyCircle FamilyCircle?
|
||||||
|
consentSetting ConsentSetting?
|
||||||
|
}
|
||||||
|
|
||||||
|
model FamilyCircle {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
elderProfileId String @unique
|
||||||
|
createdById String
|
||||||
|
name String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
elderProfile ElderProfile @relation(fields: [elderProfileId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy CaregiverAccount @relation("CircleCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||||
|
memberships FamilyMembership[]
|
||||||
|
posts FamilyPost[]
|
||||||
|
memos FamilyMemo[]
|
||||||
|
tasks CareTask[]
|
||||||
|
medicationPlans MedicationPlan[]
|
||||||
|
checkIns CheckInRequest[]
|
||||||
|
safetySignals SafetySignal[]
|
||||||
|
alerts SafetyAlert[]
|
||||||
|
memories FamilyMemory[]
|
||||||
|
auditLogs AuditLog[]
|
||||||
|
invitations FamilyInvitation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model FamilyInvitation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
familyCircleId String
|
||||||
|
createdById String
|
||||||
|
tokenHash String @unique
|
||||||
|
role FamilyRole @default(CARING)
|
||||||
|
relationLabel String?
|
||||||
|
status InvitationStatus @default(PENDING)
|
||||||
|
expiresAt DateTime
|
||||||
|
acceptedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy CaregiverAccount @relation("InvitationCreator", fields: [createdById], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([familyCircleId, status, expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model FamilyMembership {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
familyCircleId String
|
||||||
|
caregiverId String
|
||||||
|
role FamilyRole @default(CARING)
|
||||||
|
relationLabel String?
|
||||||
|
status MembershipStatus @default(ACTIVE)
|
||||||
|
emergencyPush Boolean @default(true)
|
||||||
|
routinePush Boolean @default(true)
|
||||||
|
dutyOrder Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
|
||||||
|
caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([familyCircleId, caregiverId])
|
||||||
|
@@index([caregiverId, status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model FamilyPost {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
familyCircleId String
|
||||||
|
authorId String?
|
||||||
|
content String
|
||||||
|
visibility ContentVisibility @default(FAMILY_AND_ELDER)
|
||||||
|
elderAuthored Boolean @default(false)
|
||||||
|
pinned Boolean @default(false)
|
||||||
|
deliveredAt DateTime?
|
||||||
|
readAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
|
||||||
|
author CaregiverAccount? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([familyCircleId, createdAt(sort: Desc)])
|
||||||
|
}
|
||||||
|
|
||||||
|
model FamilyMemo {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
familyCircleId String
|
||||||
|
authorId String
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
priority MemoPriority @default(NORMAL)
|
||||||
|
visibility ContentVisibility @default(FAMILY_AND_ELDER)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
|
||||||
|
author CaregiverAccount @relation(fields: [authorId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([familyCircleId, priority, updatedAt(sort: Desc)])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CareTask {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
familyCircleId String
|
||||||
|
createdById String
|
||||||
|
assignedToId String?
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
dueAt DateTime?
|
||||||
|
status CareTaskStatus @default(OPEN)
|
||||||
|
completedAt DateTime?
|
||||||
|
completionNote String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy CaregiverAccount @relation("TaskCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||||
|
assignedTo CaregiverAccount? @relation("TaskAssignee", fields: [assignedToId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([familyCircleId, status, dueAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model MedicationPlan {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
familyCircleId String
|
||||||
|
createdById String
|
||||||
|
name String
|
||||||
|
dosage String
|
||||||
|
instructions String?
|
||||||
|
timesJson String
|
||||||
|
source MedicationSource
|
||||||
|
startDate DateTime
|
||||||
|
endDate DateTime?
|
||||||
|
stockCount Int?
|
||||||
|
active Boolean @default(true)
|
||||||
|
pausedReason String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
|
||||||
|
createdBy CaregiverAccount @relation(fields: [createdById], references: [id], onDelete: Restrict)
|
||||||
|
doses MedicationDose[]
|
||||||
|
|
||||||
|
@@index([familyCircleId, active])
|
||||||
|
}
|
||||||
|
|
||||||
|
model MedicationDose {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
medicationPlanId String
|
||||||
|
scheduledFor DateTime
|
||||||
|
status MedicationDoseStatus @default(SCHEDULED)
|
||||||
|
respondedAt DateTime?
|
||||||
|
snoozedUntil DateTime?
|
||||||
|
note String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
medicationPlan MedicationPlan @relation(fields: [medicationPlanId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([medicationPlanId, scheduledFor])
|
||||||
|
@@index([scheduledFor, status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CheckInRequest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
familyCircleId String
|
||||||
|
requestedById String
|
||||||
|
prompt String?
|
||||||
|
dueAt DateTime?
|
||||||
|
status CheckInStatus @default(PENDING)
|
||||||
|
responseText String?
|
||||||
|
respondedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
|
||||||
|
requestedBy CaregiverAccount @relation(fields: [requestedById], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([familyCircleId, status, createdAt(sort: Desc)])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SafetySignal {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
familyCircleId String
|
||||||
|
category String
|
||||||
|
summary String
|
||||||
|
confidence Float?
|
||||||
|
contextText String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
|
||||||
|
alert SafetyAlert?
|
||||||
|
|
||||||
|
@@index([familyCircleId, category, createdAt(sort: Desc)])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SafetyAlert {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
familyCircleId String
|
||||||
|
safetySignalId String @unique
|
||||||
|
claimedById String?
|
||||||
|
level AlertLevel
|
||||||
|
status AlertStatus @default(OPEN)
|
||||||
|
title String
|
||||||
|
summary String
|
||||||
|
resolutionNote String?
|
||||||
|
claimedAt DateTime?
|
||||||
|
resolvedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
|
||||||
|
safetySignal SafetySignal @relation(fields: [safetySignalId], references: [id], onDelete: Cascade)
|
||||||
|
claimedBy CaregiverAccount? @relation("AlertClaimer", fields: [claimedById], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([familyCircleId, status, createdAt(sort: Desc)])
|
||||||
|
}
|
||||||
|
|
||||||
|
model FamilyMemory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
familyCircleId String
|
||||||
|
title String
|
||||||
|
story String
|
||||||
|
mediaUrl String?
|
||||||
|
occurredAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model ConsentSetting {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
elderProfileId String @unique
|
||||||
|
shareTranscripts Boolean @default(true)
|
||||||
|
shareRawAudio Boolean @default(false)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
elderProfile ElderProfile @relation(fields: [elderProfileId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
familyCircleId String
|
||||||
|
actorId String?
|
||||||
|
action String
|
||||||
|
entityType String
|
||||||
|
entityId String?
|
||||||
|
detailJson String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
familyCircle FamilyCircle @relation(fields: [familyCircleId], references: [id], onDelete: Cascade)
|
||||||
|
actor CaregiverAccount? @relation(fields: [actorId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([familyCircleId, createdAt(sort: Desc)])
|
||||||
}
|
}
|
||||||
|
|
||||||
model DeviceBinding {
|
model DeviceBinding {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
caregiverId String
|
caregiverId String
|
||||||
elderDeviceId String
|
elderDeviceId String
|
||||||
|
alias String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade)
|
caregiver CaregiverAccount @relation(fields: [caregiverId], references: [id], onDelete: Cascade)
|
||||||
elderDevice ElderDevice @relation(fields: [elderDeviceId], references: [id], onDelete: Cascade)
|
elderDevice ElderDevice @relation(fields: [elderDeviceId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
|
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) => {
|
self.addEventListener("push", (event) => {
|
||||||
let data = {
|
let data = {
|
||||||
title: "家人连线台",
|
title: "安智伴 家人连线台",
|
||||||
body: "收到一条新的家人留言。",
|
body: "收到一条新的家人留言。",
|
||||||
url: "/",
|
url: "/",
|
||||||
icon: "/pwa/icon-192x192.png",
|
icon: "/pwa/icon-192x192.webp",
|
||||||
badge: "/pwa/badge-96x96.png",
|
badge: "/pwa/badge-96x96.webp",
|
||||||
tag: "digital-human-message",
|
tag: "digital-human-message",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import sharp from "sharp";
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const projectRoot = path.resolve(__dirname, "..");
|
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 pwaOutputDir = path.join(projectRoot, "public", "pwa");
|
||||||
|
|
||||||
const androidResArg = process.argv.find((arg) =>
|
const androidResArg = process.argv.find((arg) =>
|
||||||
@@ -25,16 +25,19 @@ function createCircleMask(size) {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writePng(size, outputPath) {
|
async function writeWebp(size, outputPath) {
|
||||||
await sharp(sourceIconPath).resize(size, size).png().toFile(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)
|
const foreground = await sharp(sourceIconPath)
|
||||||
.resize(Math.round(size * 0.82), Math.round(size * 0.82), {
|
.resize(Math.round(size * 0.82), Math.round(size * 0.82), {
|
||||||
fit: "contain",
|
fit: "contain",
|
||||||
})
|
})
|
||||||
.png()
|
.webp({ quality: 88, effort: 6 })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
await sharp({
|
await sharp({
|
||||||
@@ -42,7 +45,7 @@ async function writeMaskablePng(size, outputPath) {
|
|||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
channels: 4,
|
channels: 4,
|
||||||
background: "#35548d",
|
background: "#FBFAF6",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.composite([
|
.composite([
|
||||||
@@ -51,7 +54,7 @@ async function writeMaskablePng(size, outputPath) {
|
|||||||
gravity: "center",
|
gravity: "center",
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.png()
|
.webp({ quality: 88, effort: 6 })
|
||||||
.toFile(outputPath);
|
.toFile(outputPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,11 +95,11 @@ async function writeAndroidIcons(resDir) {
|
|||||||
await mkdir(pwaOutputDir, { recursive: true });
|
await mkdir(pwaOutputDir, { recursive: true });
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
writePng(192, path.join(pwaOutputDir, "icon-192x192.png")),
|
writeWebp(192, path.join(pwaOutputDir, "icon-192x192.webp")),
|
||||||
writePng(512, path.join(pwaOutputDir, "icon-512x512.png")),
|
writeWebp(512, path.join(pwaOutputDir, "icon-512x512.webp")),
|
||||||
writePng(180, path.join(pwaOutputDir, "apple-touch-icon.png")),
|
writeWebp(180, path.join(pwaOutputDir, "apple-touch-icon.webp")),
|
||||||
writePng(96, path.join(pwaOutputDir, "badge-96x96.png")),
|
writeWebp(96, path.join(pwaOutputDir, "badge-96x96.webp")),
|
||||||
writeMaskablePng(512, path.join(pwaOutputDir, "icon-maskable-512x512.png")),
|
writeMaskableWebp(512, path.join(pwaOutputDir, "icon-maskable-512x512.webp")),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (androidResDir) {
|
if (androidResDir) {
|
||||||
|
|||||||