Initial commit: HealthCarePregnant project documentation and platform

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
selfrelease
2026-06-18 09:48:05 +08:00
commit eab91174db
301 changed files with 42491 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
# macOS
.DS_Store
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Dependencies
pcm-platform/node_modules/
# Environment
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# Build
dist/
build/
# Logs
*.log
+131
View File
@@ -0,0 +1,131 @@
18811080143-2606171157
时间: 2026年6月17日 11:57:17
主题: 18811080143-2606171157
00:02
哎陈总。
00:03
哎你好你好。对对对那个那个对,呃上午跟那个毕总我们当时通了个电话,因为那个近期啊我们是打算跟那个菲斯克一块儿,呃针对如初的这个这个资源,然后一块做一个运营平台。明白,呃因为我们这边是做这个主要是做以中医为主的这么一个这个这个这个医疗的这个机构嘛。
00:23
太好了。然后看到健康现在是是是一个核心问题。对对
00:27
对我们看到这个就是像那个孕产期间哈,其实像孕妇啊就是她这个孕孕糖就孕高血糖孕高血压这个比例是挺高的。对对对。然后我们觉得应该是给她做一个监管,呃就做一个监管的这么一个,呃就这个相当于一个一个一个一个后台吧,或者是一个程序。
00:44
对。因为我们这边的话呢就是做监管。其中有一个很重要的理念,就是呃采用了这个这个美国他们现在比较流行的一个一个一个一个体制叫个案管理师,叫case manager.啊这个这个个案管理师,那他是有一一整套的这么一个这个这个体系和标准的。Ok.
01:03
所以我们想的话呢就是说,呃当然我们也不也没必要做那么大哈,可能是想针对这个孕产这一块的,包括他的这个后台的知识库啊,还有这个这个相关的一些这个点呢,我们可能做一个就是做一个行业版的明白,行业版的这个孕产个案个案管理系统明白啊,所以我就说这个我我我觉得这
01:22
这是这是AI比较好的应用方向啊。
01:25
对垂直的嘛,非常垂直的一个东西啊,啊,嗯所以我说找时间咱们这个这个一块来筹划筹划。
01:31
啊,我觉得这事儿都不用说话,咱都可以马上就开始干。因为因为这个实际上从那个技术基础上,应该不是,就是应该应该没有什么障碍。关键其实就是关于这个体系的梳理内容的准备。啊,对包括可能未来你的业务流程的这个安排。因为是的是的,你想你这里面涉及到几个方面嘛,一个是要获取到,就是一个是你咱们自有的一套体系,对吧?
01:57
是的但是我们这套体系要获得它的输入数据,对吧?这样我才能给他建议或者是监管他的情况。没错,那那里面很重要的一点就是他的输入数据怎么来?
02:07
呃我们有刚好有一个很很方便的这个检测设备,哎,可以直接居家的。Ok,
02:13
那就好了。那这个设备它是有接口的,对不对?我们就可以从那个接口里面去获取数据,对不对?是的。啊。是的是的。Ok.那这个基本上我觉得大的问题就没有了。你看。对呀,我们获取数据是ok的。后面我们这个呃整个的这个分析和这个管理的体系是ok的。
02:32
然后我们再把知识库建好,能够让他去做一些日常的查询,对吧?对,那这个这个闭环就就基本ok了。嗯对对对对对,而且是垂类的东西呢就有价值。因为你说我做的那个什么都能干的,我操。那他瞎掰了就。
02:46
啊对我们肯定干不过那些大家伙对不对?
02:48
对对对对对对而且也没意义,因为垂类的未来其实咱不是靠它是靠你延伸的东西嘛对吧?
02:54
是的是的靠服务嘛。对对对对对。好呀好呀。那您看这样那个,因为我我是今天明天啊就特都特别忙,然后周五不是放那个就是要放假了嘛。嗯咱们节后吧,节后这个约个时间。
03:07
我觉得你看能不能这样,因为其实我现在特别喜欢放假的时候就干点儿活儿,你知道吗?那就那就那个什么,我的建议是这样的,因为这个思路基本清楚了哈。对您给我一些专业的东西,你比如嗯嗯嗯这个事儿呢,你比如这个这个这个你那个设备大概能采集什么数对吧?
03:27
啊,呃然后可以的,这是一个一个一个事儿,因为这个数是用来做分析的嘛,对吧?对对对,第二一个就是后台分析,你这个模型是现成的吗?
03:39
应该还不是应
03:40
该还不是ok,但那个理论是现成的对不对?
03:42
理论是现成的,我们可以有专业的医生去来介入啊。Ok,
03:46
那那个理论的介绍资料你能给我点吗?
03:50
啊,我这个得因为没关系。
03:53
你给我个名字都行,我去查都行。我也不是搞医的。这个没关系没关系,你给我个名字我去查都行。因为因为这个东西它是general指的知识嘛,对吧?好好好好好好。这是第二个,第三一个呢就是未来我们的知识库构建是谁来做啊?
04:09
嗯就是你说是内容是吗?
04:11
内容输入,内容内容内容内容输入。
04:14
我们可以输入一部分,然后更多的话可能还是通过这个比如说网络啊或者现成的出版物啊,就这些
04:20
就是我们实际上还是找一些权威的资料放进去,对吧?对的对的对的,然后我们再结合他的实际情况给他一些输出是吧?对的对的ok的这种结构我了解了,那其实可能最关键的就是您把那个你那个仪器采集的数据告诉我就行了,啊其他的然后没问题,然后另外就那个你那个你们依据的那个理论的名称告诉我就ok了。
04:40
嗯嗯啊嗯。然后我们现在主要方向不就是孕产期的这个孕妇的这个高血压和高血糖的监控吗?对吧?呃不仅是这些东西。
04:49
还有其他的比如说可能有几十种这个指标呢,然后因为孕糖孕糖孕高的话。
04:55
反正我们就是以这个孕妇为主呗。对吧?对对对对对ok行使用对象是孕妇吧对吧对对对对对ok.
05:03
然后身体里的这个维生素D的这个含量啊,因为这个跟胎儿的明白
05:06
明白明白。那但是咱们这个检测设备都能检测出来吗?能能能能啊。
05:10
那就行都能那就行那就行。嗯ok嗯好好好好。
05:14
那您就给我点这些资料,然后我可能得稍微晚一点。
05:18
因为没关系没关系,我在外面啊。
05:20
啊没关系。然后我可能就到时候先搭一个简单的这个东西出来,这样咱们聊起来就就就比较聚焦对吧?否则现在已经不需要说说说说概念了,直接呢可以看这个基础的东西。行行
05:35
行行行好ok好
05:37
好嘞行,太好了啊。您您在北京吗?我都在北京啊。行行行。那到时候我反正也在北京多,咱们到时候。好嘞好嘞好嘞,到时候有时间再碰。好吧,好嘞,好嘞,好嘞,ok,
05:47
好好好好。
05:47
嗯嗯拜拜。
05:48
嗯好拜拜嗯。
+657
View File
@@ -0,0 +1,657 @@
# 孕产个案管理平台 · 基础构想
- **背景来源**2026年6月17日 11:57 电话沟通(详见原始资料 0-原始资料.txt)
- **主题**:联合打造垂直领域的"孕产个案管理系统",以 AI 全面数字化支撑个案管理
## 合作方与分工
| 角色 | 主体 | 职责 |
|------|------|------|
| **运营方(需求方)** | 陈总,以**中医为主**的医疗机构(联合菲斯克,基于"如初"资源)| 提供业务体系、检测设备与数据、医学理论依据、知识库内容,负责运营与服务 |
| **技术方(建设方)** | 我方 | 负责系统设计与搭建、AI 能力建设、数据/分析/知识库的工程实现 |
---
## 一、合作背景
- **运营方**为以**中医为主**的医疗机构,拟联合菲斯克(基于"如初"资源)共同打造一个**孕产健康运营平台**。
- 切入点:孕产期健康监管。孕妇**妊娠期糖尿病、妊娠期高血压**等发生比例高,存在明确的监管需求。
- 定位:不做大而全,聚焦孕产领域,做一个**垂直行业版的"孕产个案管理系统"**——这也被双方视为 **AI 落地的优质垂直方向**
## 二、核心理念
- 借鉴美国流行的 **个案管理师(Case Manager** 体制,该体制已有成熟的体系与标准。
- **中西医结合**:运营方以中医为主,平台在西医监测指标(血糖、血压等)之外,融入**中医体质辨识、辨证调养、孕期食疗与养护**,形成区别于通用孕产 App 的差异化护城河。
- 围绕孕产场景构建后台知识库及相关功能模块,形成专业、垂直的解决方案。
- 所依据的医学理论是**通用、可公开查证的知识(general knowledge)**,由专业医生介入即可落地,知识库可基于权威公开资料构建。
- 共识:垂直领域才有价值,竞争力不在于工具本身,而在于**延伸的服务**。
## 三、AI 的核心定位(系统灵魂)
AI 不是某个附加功能,而是贯穿全平台的中枢,目标是**全面数字化地支撑个案管理**,通过**人机协同**让个案管理师达到最高管理水平,保障孕妇健康与完美孕期体验。AI 承担以下角色:
| AI 角色 | 作用 |
|---------|------|
| **交互中枢** | 成为孕妇、个案管理师、医生、设备等各方的统一交互入口与协同纽带 |
| **知识库服务** | 基于权威知识库提供智能问答与日常查询,随时为各方提供专业解答 |
| **健康分析** | 实时分析设备采集的多项指标,识别异常、推断风险 |
| **趋势预测** | 基于纵向时间序列数据预测健康走向(如风险演变、指标趋势)|
| **管理支持** | 为个案管理师提供决策建议、计划生成、预警处置辅助,实现人机协同 |
> 核心理念:**AI 赋能、人机协同、医生兜底**。AI 负责全量数据处理、分析、预测与建议,个案管理师与医生负责判断与决策,二者协同实现"最高水平的孕期管理"。
## 四、闭环逻辑(达成一致)
1. **数据采集**:通过居家检测设备获取孕妇体征数据(设备带接口,可直接对接取数)。
2. **分析与管理**:基于专业医学理论建立分析/管理体系(模型暂未现成,但理论成熟,可由专业医生介入)。
3. **知识库**:导入权威资料(部分自有内容 + 网络/出版物等权威来源),支持日常查询。
4. **输出建议**:结合孕妇实际情况,给出监管建议与输出。
> 技术上无明显障碍,关键在于**体系梳理、内容准备和业务流程设计**。
## 五、产品范围
- **使用对象**:孕妇(孕产期为主)。
- **监测指标**:不止血糖、血压,涵盖数十种指标,例如维生素D含量(与胎儿发育相关)等(**具体以设备实际可测项为准,见附录五待验证假设**)。
- **服务与体验**:在健康监管之外,平台承载**孕期关怀与延伸服务**——健康教育、个性化指导、中医调养、答疑陪伴、孕期里程碑提醒等,目标是"健康监管 + 完美孕期体验",也是商业竞争力的核心(竞争力来自延伸服务而非工具本身)。
- **健康习惯提醒**:基于时间与计划,定时提醒**运动、休息、喝水、服药、产检、监测打卡**等;提醒内容**个性化**——运动提醒须按孕周与风险分层动态调整(高风险孕妇改为"休息/放松"而非运动),避免一刀切。
- **游戏化与轻量小游戏**:通过**积分、徽章、连续打卡、轻量小游戏(如消消乐、正念放松、孕期知识问答闯关)**提升依从性、缓解焦虑、增强留存。需把握孕期分寸:**限制单次时长避免久坐刷屏、低压力非强竞技、可反向触发"起身活动"提醒**。
- **体验原则**:持续监测与预警本身可能加重孕妇焦虑,交互须**有温度、分级提醒、避免过度医疗化**,在"安心"与"警觉"之间取得平衡。
## 六、MVP 与演进策略
- **不追求一步到位**:首个原型聚焦**一个高价值闭环**(建议:妊娠期糖尿病 GDM 血糖管理——采集→规则预警→管理师干预→智能问答),验证后再横向扩展指标与场景。
- **冷启动路线**:知识库与智能问答**先行可用**;分析**先用规则引擎**(医生定义阈值),积累数据后再引入数据驱动模型与趋势预测。
- **演进**:单场景闭环 → 多指标覆盖 → 中西医结合调养 → 趋势预测 → 全量个案管理。
## 七、成功度量(KPI
| 维度 | 指标示例 |
|------|----------|
| 监管有效性 | 预警及时率、预警准确率(误报/漏报)、高风险转诊及时率 |
| 用户依从 | 监测依从率、漏测率、计划执行率 |
| 健康结局 | 血糖/血压达标率、孕期不良结局发生率下降 |
| 体验与留存 | 用户留存率、满意度、焦虑量表改善 |
| 运营 | 单个案管理师可管理人数(人机协同效率提升)|
## 八、商业模式(待展开)
- **B2B**:向中医机构/月子中心/产科诊所提供平台与个案管理 SaaS。
- **B2C**:孕妇订阅制健康管理与关怀服务。
- **设备 + 服务**:检测设备 + 监管服务捆绑。
- **延伸服务变现**:营养、调养、母婴课程、转诊与增值医疗服务(呼应"竞争力靠延伸服务")。
## 九、待办事项(Next Steps
| 负责方 | 事项 |
|--------|------|
| 运营方(陈总)| 1. 提供检测设备可采集的**数据指标清单 + 精度 + 认证情况**(关键,见附录五)|
| 运营方(陈总)| 2. 提供所依据的**医学理论名称/介绍资料**(名称即可,便于技术方查阅)|
| 技术方(我方)| 3. 先**搭建一个基础原型**(建议从 GDM 单场景闭环切入),让讨论从概念转向可见系统 |
| 双方 | 4. 节后约时间当面碰(双方均在北京)|
## 十、待确认事项
- 后台分析模型需从零构建(理论现成、通用,模型待开发)。
- 知识库内容来源与权威性把控方式需进一步明确。
- 业务流程与服务延伸的具体安排尚待规划。
- AI 各能力(交互、问答、分析、预测、管理支持)的优先级与原型范围待划定。
- **合规定位、设备能力、临床安全等关键风险见附录五,需优先确认。**
---
# 附录:本体分析(Ontology Analysis
> 说明:以下本体分析用于明确"分析模型"与"知识库"两大核心模块的概念结构、实体关系与运转逻辑,并将"个案管理师(Case Manager)"体制作为贯穿全局的方法论框架。本体(Ontology)即对领域内**概念、属性、关系、约束**的形式化定义,是后续建模、建库与系统设计的语义基础。
>
> 内容基于通行的个案管理标准框架(CMSA《个案管理实践标准》、CCMC 认证体系)与孕产母胎监护通行实践整理;具体医学阈值与理论依据需由专业医生最终确认。
## 一、个案管理师(Case Manager)体制本体
### 1.1 体制定义
个案管理是一种以**患者为中心**的协作式服务过程,通过评估、规划、协调、监督和倡导,整合医疗资源,满足个体的健康需求,兼顾**质量与成本**。个案管理师是这一过程的责任主体。
### 1.2 个案管理师的角色(Roles)
| 角色 | 职责 |
|------|------|
| 评估者 Assessor | 收集并评估孕妇的健康、风险、社会与心理状况 |
| 规划者 Planner | 制定个性化照护计划与目标 |
| 协调者 Coordinator | 协调医生、设备、家庭、机构等多方资源 |
| 监督者 Monitor | 持续跟踪指标、依从性与计划执行情况 |
| 教育者 Educator | 提供健康教育与自我管理指导 |
| 倡导者 Advocate | 代表孕妇利益,争取适当的医疗与支持资源 |
### 1.3 个案管理流程(Process Phases)—— 系统状态机的核心
标准个案管理是一个**闭环循环**,可直接映射为系统的核心工作流:
```
筛查 Screening
→ 评估 Assessment
→ 风险分层 Risk Stratification
→ 计划制定 Planning
→ 计划实施/协调 Implementation & Coordination
→ 监测与随访 Monitoring & Follow-up
→ 评价 Evaluation
→ (达标转出 Transition / 未达标回到评估)
```
> 该流程是"分析模型"与"业务流程"的骨架:每一阶段都有明确的输入、输出、判定条件与责任角色,适合用状态机或工作流引擎实现。
> **红旗(Red Flag)急症旁路**:上述循环适用于常规监管节奏,但**危急情况不能走常规流程**。当出现危急组合(如血压骤高 + 剧烈头痛 + 视物模糊提示子痫前期、胎动消失、阴道大量出血等)时,系统须**绕过常规循环直接升级**——立即提示就医/转急诊并通知医生。这是临床安全底线,见附录五。
### 1.4 体制核心概念(Class
- **个案 Case**:一名孕妇的完整管理实例,贯穿整个孕产周期。
- **照护计划 Care Plan**:目标、干预措施、随访频率的集合。
- **干预 Intervention**:针对特定风险/指标的具体动作,包括:临床类(用药提醒、转诊)、生活方式类(饮食、运动、休息、喝水指导)、**习惯养成/游戏化类**(打卡、积分、徽章、轻量小游戏)。运动类干预须按孕周与风险分层个性化。
- **提醒 Reminder**:基于时间与计划触发的定时推送(运动/休息/喝水/服药/产检/监测打卡),由 AI 交互中枢执行,是提升依从性的关键手段。
- **风险分层 Risk Level**:低/中/高风险,决定监测强度与干预力度。
- **依从性 Compliance**:孕妇对监测与计划的执行程度。
### 1.5 干系人(Stakeholders
除孕妇与个案管理师外,体系还涉及以下角色,影响交互、协同与转诊设计:
| 干系人 | 作用 |
|--------|------|
| 孕妇 Patient | 服务核心对象 |
| 个案管理师 Case Manager | 管理主体、人机协同核心 |
| 医生 Physician | 临床决策与高风险兜底 |
| 营养师 / 中医调养师 | 饮食、体质调养指导 |
| 家属 Family | 协助监测、情感支持 |
| 产科医院 / 转诊网络 | 急症与高风险转诊承接 |
| 运营客服 Operator | 服务运营、用户触达 |
| 数据合规员 DPO | 数据安全与隐私合规(见附录五)|
## 二、分析模型本体(Analysis Model Ontology
### 2.1 核心实体(Entities / Classes
| 实体 | 说明 | 关键属性 |
|------|------|----------|
| 孕妇 Patient | 管理与服务对象 | 孕周、年龄、既往史、孕次、风险等级 |
| 检测设备 Device | 居家数据采集来源 | 设备类型、接口、采集指标、精度 |
| 观测值 Observation | 单次指标测量结果 | 指标类型、数值、单位、时间戳、来源设备 |
| 指标 Indicator | 被监测的体征/生化项 | 名称、正常范围、孕期分段阈值、单位 |
| 风险 Risk | 由指标推断的健康风险 | 风险类型、等级、触发规则、置信度 |
| 预测 Prediction | 基于纵向时序数据对未来走向的预判 | 预测目标、时间窗、概率/趋势、依据数据 |
| 规则 Rule | 指标→风险的判定逻辑 | 条件、阈值、孕周适配、动作 |
| 预警 Alert | 超阈值或异常触发的事件(跨本体共享:在体制本体中由个案管理师处置)| 等级、关联指标、处置状态 |
| 照护计划 Care Plan | 分析结论驱动的干预方案 | 目标、措施、随访频率 |
| 个案管理师 Case Manager | 决策与服务责任人(跨本体共享:体制本体的核心角色)| 负责个案、处理预警 |
### 2.2 监测指标体系(Indicator Taxonomy
> 以孕妇为对象,覆盖数十种指标。以下为分类框架,具体指标清单待运营方提供设备采集清单后补全。
- **代谢类**:血糖(空腹/餐后,妊娠期糖尿病 GDM 监测核心)、糖化血红蛋白等
- **心血管类**:血压(收缩压/舒张压,妊娠期高血压/子痫前期监测核心)、心率
- **营养/微量元素类**:维生素D(与胎儿骨骼发育相关)、铁、钙等
- **胎儿相关**:胎动、胎心(如设备支持)
- **中医辨证维度**:体质类型、舌象/脉象(如可采集)、症状证候,用于辨证调养建议
- **其他生化指标**:数十项,由检测设备能力决定(**实际可测项以设备能力为准,见附录五**)
> **红旗指标组合**:部分指标的特定组合构成急症信号(如血压骤升 + 头痛 + 视物模糊),须配置为旁路升级规则,直接触发就医提示。
### 2.3 分析模型的推理链(Reasoning Chain
```
设备采集 → 观测值 Observation
→ 与指标阈值/孕周分段标准比对(Rule)
→ 推断风险 Risk + 生成预警 Alert
→ 触发照护计划/干预 Intervention
→ 个案管理师审核与处置
→ 随访复测 → 更新风险评估(闭环)
```
> 模型现状:**理论成熟、模型待建**。建议采用"规则引擎(临床阈值/指南规则)+ 后续数据驱动模型"的渐进路线,初期以可解释的规则为主,由专业医生定义阈值与孕周分段标准。
### 2.4 关键约束(Constraints
- 指标阈值需按**孕周分段**(孕早/中/晚期标准不同)。
- 风险判定必须**可解释、可追溯**到具体规则与观测值。
- 高风险预警必须有**人工(个案管理师/医生)介入**环节,系统不替代临床决策。
## 三、知识库本体(Knowledge Base Ontology
### 3.1 知识库定位
为分析模型提供**判定依据**,为孕妇/管理师提供**日常查询**支持,形成"采集—分析—知识—建议"的闭环。
### 3.2 知识实体(Knowledge Entities
| 实体 | 说明 |
|------|------|
| 知识条目 Knowledge Item | 最小知识单元(一条结论/解释/建议)|
| 临床指南 Guideline | 权威诊疗/管理标准(如妊娠期糖尿病、妊娠期高血压指南)|
| 中医调养知识 TCM Knowledge | 体质辨识、辨证、食疗与孕期养护知识 |
| 指标释义 Indicator Reference | 各指标的含义、正常范围、异常意义 |
| 干预知识 Intervention Knowledge | 针对风险的处置/生活方式建议 |
| 来源 Source | 知识出处(权威出版物、网络资料、自有内容)|
| 主题/标签 Topic | 知识分类与检索维度 |
### 3.3 知识来源与可信度(Source & Provenance
- **自有内容**:机构专家/医生输入的一部分专业内容。
- **权威外部来源**:现成出版物、权威网络资料。
- **可信度约束**:每条知识条目须标注来源与权威级别,支持溯源;优先采用权威指南。
### 3.4 知识库与分析模型的关系
```
知识库 Guideline / Rule Reference
↓ 提供阈值与判定依据
分析模型 Rule / Risk
↓ 产生风险结论与建议
知识库 Intervention Knowledge
↓ 支撑建议输出与日常查询
孕妇 / 个案管理师
```
> 知识库与分析模型并非独立:知识库中的**指南与指标释义**是分析规则的来源,分析模型产生的**风险**又关联到知识库中的**干预建议**,二者通过"指标—规则—风险—干预"语义链耦合。
## 四、整体本体关系图(概念视图)
```mermaid
graph TD
CM[个案管理师 Case Manager] -->|负责| Case[个案 Case]
Case -->|属于| Patient[孕妇 Patient]
Device[检测设备] -->|采集| Obs[观测值 Observation]
Obs -->|关联| Indicator[指标 Indicator]
Indicator -->|阈值依据| KB[知识库: 指南/释义]
Obs -->|经规则判定| Rule[规则 Rule]
Rule -->|推断| Risk[风险 Risk]
Risk -->|触发| Alert[预警 Alert]
Risk -->|匹配| Intervention[干预 Intervention]
Intervention -->|内容来自| KB
Alert -->|处置| CM
CM -->|制定| CarePlan[照护计划 Care Plan]
CarePlan -->|包含| Intervention
Patient -->|查询| KB
```
## 五、待运营方/医生确认的本体细节
1. 设备可采集的**完整指标清单**及各指标接口字段。
2. 各指标的**孕周分段阈值**与异常判定标准(理论依据/指南名称)。
3. 个案管理师体制落地时的**角色边界**(系统自动 vs 人工介入的分界)。
4. 知识库**权威来源清单**与内容审核流程。
---
# 附录二:三大本体的数据融合(Ontology Data Fusion
> 目标:让"个案管理师体制""分析模型""知识库"三个本体**不各自为政**,而是融合为一张统一的语义网络(知识图谱)。融合的核心是——同一个孕妇、同一项指标、同一条风险,在三个本体中指向**同一份数据实体**,从而实现"采集即分析、分析即关联知识、知识即驱动管理动作"的全链路打通。
## 一、融合原则
1. **统一锚点(孕妇为中心)**:所有数据围绕同一个 `孕妇ID` 聚合,个案、观测值、风险、计划、查询记录全部挂接到同一人。
2. **共享语义骨架**:三个本体共用一条主链——`指标 → 规则 → 风险 → 干预 → 知识`,任一本体引用的概念都来自同一套术语表(统一词表 / 编码标准)。
3. **单一事实来源(Single Source of Truth**:每个实体(如"血压""妊娠期高血压")在系统中只定义一次,三方引用同一定义,避免重复与冲突。
4. **可追溯(Provenance**:任何一条风险结论都能反向追溯到"哪台设备、哪次观测、依据哪条规则、引用哪条指南、由哪位个案管理师处置"。
## 二、融合的关键连接点(Join Keys)
三个本体通过以下共享标识实现数据对齐:
| 共享标识 | 连接的本体 | 作用 |
|----------|-----------|------|
| `孕妇ID Patient ID` | 体制 ↔ 分析模型 ↔ 知识库 | 全局锚点,聚合一个人的所有数据 |
| `指标ID Indicator ID` | 分析模型 ↔ 知识库 | 观测值、规则阈值、指标释义共用同一指标定义 |
| `风险ID Risk ID` | 分析模型 ↔ 体制 ↔ 知识库 | 风险既触发管理动作,又关联干预知识 |
| `干预ID Intervention ID` | 体制 ↔ 知识库 | 照护计划中的干预,其内容来自知识库 |
| `指南ID Guideline ID` | 知识库 ↔ 分析模型 | 规则阈值的依据来源,可溯源 |
| `规则ID Rule ID` | 分析模型(内部)| 标识每条判定逻辑,供风险溯源 |
| `预警ID Alert ID` | 分析模型 ↔ 体制 | 风险触发的预警,关联到管理处置动作 |
> 实现要点:**指标与术语必须统一编码**(建议对接通行医学术语标准,如 LOINC 检验项、SNOMED CT 临床概念、ICD 诊断),保证设备数据、规则、知识三方"说同一种语言"。
## 三、融合后的统一数据视图
```mermaid
graph TD
P[孕妇 Patient<br/>统一锚点]
subgraph 分析模型本体
O[观测值 Observation]
I[指标 Indicator]
R[规则 Rule]
RK[风险 Risk]
end
subgraph 知识库本体
G[临床指南 Guideline]
IR[指标释义]
IK[干预知识]
end
subgraph 个案管理师体制本体
CM[个案管理师]
CASE[个案 Case]
CP[照护计划]
IV[干预 Intervention]
AL[预警 Alert]
end
P --> O
P --> CASE
O -->|同一指标ID| I
I -->|同一指标ID| IR
R -->|阈值依据/同一指南ID| G
O --> R --> RK
RK -->|同一风险ID| AL
RK -->|同一风险ID| IK
AL --> CM --> CP --> IV
IV -->|内容来自/同一干预ID| IK
P -.日常查询.-> G
P -.日常查询.-> IR
```
> 这张图体现"三本体合一":孕妇产生的每个观测值,自动经规则与知识库阈值判定为风险,风险同时驱动**管理动作(预警→个案管理师→计划→干预)**并关联**知识库干预内容**,孕妇还能直接查询同一知识库。数据一次录入、全链路复用。
## 四、数据融合的四个层次
| 层次 | 内容 | 说明 |
|------|------|------|
| 1. 标识融合 | 统一ID与编码标准 | 三本体引用同一实体,消除"同物异名" |
| 2. 模式融合 Schema | 统一数据模型/本体定义 | 用一套 Ontology/知识图谱统一描述实体与关系 |
| 3. 数据融合 Instance | 多源数据归一 | 多设备、多次测量、多来源知识归并到同一实体下 |
| 4. 语义融合 Semantic | 跨本体推理 | 可沿"观测→风险→干预→知识"链做联合推理与解释 |
## 五、多源数据的融合策略
- **设备多指标融合**:同一孕妇多台/多次设备数据,按 `孕妇ID + 指标ID + 时间戳` 归一为统一时间序列,便于趋势分析。
- **知识多来源融合**:自有内容 + 权威出版物 + 网络权威资料,统一为知识条目并标注来源与权威级别;冲突时按权威级别仲裁。
- **风险多规则融合**:一个风险可能由多条规则/多项指标共同支持,融合时记录所有触发依据与置信度,保证可解释。
- **时间维度融合**:所有数据按**孕周时间轴**对齐,使指标趋势、风险演变、干预效果可在同一时间线上比对。
## 六、融合的落地建议
1. 先建立**统一术语表 / 指标字典**(待运营方提供设备指标清单后落定编码)。
2. 以**知识图谱 / 本体库**作为融合载体,承载三本体的实体与关系。
3. 分析规则、知识条目、管理动作均通过**共享ID**引用同一实体,禁止各模块私自定义。
4. 全链路保留 `Provenance`(来源—观测—规则—指南—处置人),支撑可追溯与合规。
---
# 附录三:孕妇本体(以时间进程为主轴的纵向聚合本体)
> 目标:构建一个**以孕妇为唯一锚点、以孕周时间轴为主轴**的纵向本体(Longitudinal Patient Ontology)。把这名孕妇的静态属性、动态数据、指标、规则、风险、干预、管理动作与知识引用,全部按"时间进程"汇集到同一条时间线上,形成"一人一档、随孕周生长"的活体档案。
>
> 这是前述"三大本体数据融合"在**单个孕妇维度**上的实例化:所有数据通过 `孕妇ID + 孕周/时间戳` 聚合,可回放任意时刻的健康全貌与风险演变。
## 一、本体分层结构
孕妇本体由"一个恒定核 + 一条时间轴 + 多条挂接数据流"组成:
```
孕妇本体 Patient Ontology
├── ① 静态身份层(恒定属性,孕期内基本不变)
├── ② 时间主轴层(孕周时间线 + 关键节点)
└── ③ 时序挂接层(随时间生长的动态数据,全部挂到时间轴上)
├── 观测值流 Observations
├── 指标趋势 Indicator Trends
├── 风险演变 Risk Evolution
├── 预警事件 Alerts
├── 干预与管理动作 Interventions / Case Actions
└── 知识引用 Knowledge References
```
## 二、① 静态身份层(恒定属性)
| 属性 | 示例/说明 |
|------|-----------|
| 孕妇ID | 全局唯一锚点 |
| 基本信息 | 年龄、身高、孕前体重、BMI |
| 孕产史 | 孕次、产次、既往不良孕产史 |
| 既往病史 | 高血压、糖尿病、甲状腺疾病等基础疾病 |
| 家族史 | 糖尿病、高血压、遗传病家族史 |
| 本次妊娠基线 | 末次月经(LMP)、预产期(EDD)、受孕方式、单/多胎 |
| 风险基线 | 入组初始风险分层(高龄、既往 GDM 妊娠期糖尿病 / PIH 妊娠期高血压史等) |
> 静态层决定**初始风险基线**与个性化阈值(如高龄、既往妊娠期糖尿病史者监测更严)。
## 三、② 时间主轴层(孕周时间线)
以**预产期/末次月经**推算孕周(Gestational Week),划分标准分期,并标注关键时间节点:
| 分期 | 孕周 | 阶段意义 |
|------|------|----------|
| 孕早期 | 0–13 周 | 建档、基线评估、早期风险筛查 |
| 孕中期 | 14–27 周 | GDM 筛查窗口、结构监测、稳定监管期 |
| 孕晚期 | 28–40+ 周 | 高血压/子痫前期高发、胎儿监护强化 |
| 产后 | 分娩后 | 转归评估、产后随访、计划转出 |
> 时间轴是本体主键之一:所有动态数据均带 `时间戳/对应孕周`,可按时间轴回放、对齐、比对。
## 四、③ 时序挂接层(随时间生长的动态数据)
下表展示各孕期挂接到时间轴上的核心内容(具体指标与阈值待医生/设备清单确认):
| 孕期 | 重点监测指标 | 主要风险 | 监测频率(示例)| 管理动作 |
|------|-------------|----------|----------------|----------|
| 孕早期 | 血压、基础血糖、维生素D、基础生化 | 基础病恶化、营养缺乏 | 较低频 | 建档、基线评估、健康教育 |
| 孕中期 | 血糖(GDM筛查)、血压、体重、维生素D | 妊娠期糖尿病 GDM | 中频 | 风险分层调整、饮食/运动指导 |
| 孕晚期 | 血压、血糖、胎动/胎心、体重 | 妊娠期高血压/子痫前期 | 高频 | 强化监测、预警处置、必要转诊 |
| 产后 | 血压、血糖恢复、营养 | 产后高血压/血糖未恢复 | 随访 | 转归评估、随访、计划转出 |
## 五、纵向聚合数据模型(概念)
每条动态数据都是挂在时间轴上的"节点",统一结构如下:
```
TimelineEntry {
孕妇ID
时间戳 / 孕周
类型: 观测值 | 风险 | 预警 | 干预 | 管理动作 | 知识引用
关联实体ID: 指标ID / 风险ID / 干预ID / 指南ID
数值 / 状态
来源 Provenance: 设备 / 规则 / 个案管理师 / 知识库
关联个案管理流程阶段: 评估/分层/计划/监测/评价
}
```
> 由此,孕妇本体 = 静态核 + 一串按时间排序的 TimelineEntry。任取一个孕周即可还原当时的指标值、风险等级、正在执行的计划与依据。
## 六、时序规则(Temporal Rules)—— 时间进程带来的分析能力
把数据按时间汇集后,分析不再局限于"单点超标",而能做**纵向时序判断**:
- **趋势规则**:血压/血糖连续多次上升 → 即使未超阈值也预警。
- **变化率规则**:体重短期内异常增长 → 子痫前期风险信号。
- **孕周适配规则**:同一数值在不同孕周判定不同(阈值随孕周动态切换)。
- **跨期对比**:本期指标与基线/上期对比,评估干预效果。
- **依从性时序**:监测中断/漏测的时间分布,触发提醒。
## 七、孕妇本体时间轴视图
```mermaid
timeline
title 孕妇本体:按时间进程聚合
建档(孕早期 0-13w) : 静态基线录入 : 初始风险分层 : 基础指标采集
孕中期(14-27w) : GDM血糖筛查 : 血压/维生素D监测 : 风险分层调整 : 饮食运动干预
孕晚期(28-40w) : 血压强化监测 : 子痫前期预警 : 胎动胎心跟踪 : 高风险人工介入
产后 : 指标恢复评估 : 产后随访 : 转归与计划转出
```
```mermaid
graph LR
PID[孕妇ID + 静态身份层] --> T[孕周时间主轴]
T --> E1[孕早期 Entries]
T --> E2[孕中期 Entries]
T --> E3[孕晚期 Entries]
T --> E4[产后 Entries]
E1 -.观测/风险/干预/知识.-> DATA[(统一TimelineEntry)]
E2 -.观测/风险/干预/知识.-> DATA
E3 -.观测/风险/干预/知识.-> DATA
E4 -.观测/风险/干预/知识.-> DATA
```
## 八、与三大本体的对应关系
| 孕妇本体的层 | 来自哪个本体 |
|--------------|--------------|
| 静态身份层、初始分层 | 个案管理师体制(评估/分层)|
| 观测值、指标、规则、风险 | 分析模型本体 |
| 干预内容、指标释义、指南依据 | 知识库本体 |
| 预警处置、照护计划、随访转出 | 个案管理师体制(计划/监测/评价)|
> 即:孕妇本体是三大本体在**一个人 + 一条时间轴**上的汇流点,实现"全部内容按时间进程汇集"。
## 九、待确认项
1. 孕周阈值的**动态切换标准**(各期各指标的正常范围)。
2. 各期**监测频率**的实际设定(示例频率需医生确认)。
3. 静态层中纳入哪些**风险基线因子**用于个性化阈值。
4. 时序规则中"趋势/变化率"的具体判定参数。
---
# 附录四:AI 能力本体(AI 如何嵌入三大本体)
> 目标:把正文"AI 的核心定位(系统灵魂)"在本体层落地。AI 不是独立模块,而是**贯穿个案管理师体制、分析模型、知识库、孕妇本体的能力层**,通过人机协同支撑个案管理师达到最高管理水平。本附录定义 AI 的能力实体、与各本体的嵌入关系,以及人机协同的边界。
## 一、AI 能力实体(AI Capability Entities
| AI 能力 | 对应实体 | 输入 | 输出 | 嵌入的本体 |
|---------|----------|------|------|-----------|
| 交互中枢 | 会话 Conversation / 交互事件 Interaction | 各方提问、指令 | 应答、路由、协同动作 | 贯穿全部(人、孕妇、设备入口)|
| 知识库服务 | 智能问答 QA Session | 自然语言问题 + 知识库 | 带溯源的专业解答 | 知识库本体 |
| 健康分析 | 分析任务 Analysis Task | 观测值 + 规则/指南 | 风险 Risk + 预警 Alert | 分析模型本体 |
| 趋势预测 | 预测 Prediction | 纵向时序数据(孕妇本体)| 未来走向/概率/预后 | 分析模型本体 + 孕妇本体 |
| 管理支持 | 决策建议 Recommendation | 风险/预测/计划现状 | 计划建议、处置辅助 | 个案管理师体制本体 |
## 二、AI 在分析推理链中的位置
在原"采集→风险→干预"链上显式标出 AI 与人的分工:
```
设备采集 → 观测值
→ [AI·健康分析] 规则/指南比对 → 风险 Risk + 预警 Alert
→ [AI·趋势预测] 基于时序数据 → 预测 Prediction
→ [AI·管理支持] 生成决策建议 Recommendation
→ 个案管理师审核与决策(人) ← 人机协同关键节点
→ 照护计划/干预(AI 辅助执行:提醒、随访、教育)
→ [AI·交互中枢] 与孕妇/医生交互、答疑、关怀
→ 随访复测 → 闭环
```
> 原则:**AI 负责全量处理、分析、预测与建议;个案管理师/医生负责判断与决策;高风险必须由人确认(医生兜底)**。
## 三、人机协同模型(Human-AI Collaboration
| 环节 | AI 承担 | 人(个案管理师/医生)承担 |
|------|---------|--------------------------|
| 数据处理与分析 | 全量、实时 | 抽检、校准 |
| 风险识别 | 自动推断 + 置信度 | 复核异常与低置信结论 |
| 趋势预测 | 模型预测 | 临床判断与采纳 |
| 计划与干预 | 生成建议、执行提醒 | 审核、确认、个性化调整 |
| 高风险处置 | 预警、辅助 | **决策主体(兜底)** |
| 日常交互/关怀 | 主力(问答、教育、陪伴)| 必要时介入 |
## 四、AI 嵌入四大本体的关系图
```mermaid
graph TD
subgraph AI 能力层
AICONV[交互中枢 Conversation]
AIQA[知识库服务 QA]
AIANA[健康分析 Analysis]
AIPRED[趋势预测 Prediction]
AIREC[管理支持 Recommendation]
end
PATIENT[孕妇本体<br/>纵向时序数据] --> AIANA
PATIENT --> AIPRED
KB[知识库本体] --> AIQA
KB --> AIANA
AIANA --> RISK[分析模型: 风险/预警]
AIPRED --> RISK
RISK --> AIREC
AIREC --> CM[个案管理师 人]
CM -->|决策/兜底| PLAN[体制本体: 照护计划/干预]
AICONV -->|答疑/关怀/协同| CM
AICONV -->|答疑/教育/陪伴| MOM[孕妇 / 各方]
PLAN --> PATIENT
```
> 这张图体现"AI 是贯穿四大本体的能力层":AI 从孕妇本体与知识库取数,做分析与预测,产出建议交个案管理师决策,并作为交互中枢服务各方——人机协同形成完整闭环。
## 五、AI 约束与待确认项
### 5.1 AI 关键约束(必须遵守)
- **防幻觉**:所有医学问答与建议采用 **RAG(检索增强)+ 强制溯源**,只能基于知识库内容作答,**禁止模型自由生成超纲医学建议**;无依据时应明确"无法回答/建议咨询医生"。
- **可解释与留痕**:AI 的每条风险/预测/建议都须可解释、可追溯(依据哪些数据与知识),全程留痕以支撑合规与责任界定。
- **冷启动**:自有数据不足时,分析**先用规则引擎**,趋势预测待数据积累后引入;模型须经**回顾性验证 → 前瞻性验证**方可上线。
- **医生兜底**:高风险结论与急症一律由人确认,AI 不替代临床决策。
### 5.2 待确认项
1. 各 AI 能力的**自动化程度边界**(哪些可自动执行、哪些必须人工确认)。
2. 趋势预测的**目标与可用数据**(预测哪些结局、需要多长时间序列)。
3. 交互中枢面向不同角色(孕妇/管理师/医生)的**权限与话术边界**。
4. AI 建议的**可解释性与留痕**要求(合规与责任界定)。
---
# 附录五:关键风险、合规与待验证假设
> 说明:本附录汇集决定项目基本盘的高优先风险与必须尽早确认的假设。这些问题若不提前定调,后续立项与开发会反复返工,且可能触及合规红线。**建议在搭建原型前优先澄清第一、二节。**
## 一、监管定位(最高优先)
- **核心问题**:本系统是否构成**医疗器械软件(SaMD)**?在中国,软件若对疾病做出诊断或直接影响临床判断的输出,可能触发 **NMPA 二类/三类医疗器械注册**(周期长、成本高)。
- **建议定位**:明确为 **"健康管理 / 临床决策支持",而非"诊断"**。系统提供提示、建议与监测,**临床判断与诊断始终归医生**。这样可将监管等级与责任风险降到最低,并与"医生兜底"原则一致。
- **宣传合规**:对外不得宣称"诊断、治疗、治愈",遵守《广告法》与医疗宣传规定。
- **诊疗资质**:若涉及医生在线开方/诊断,需相应**互联网医院/诊疗资质**。
## 二、数据合规与隐私(最高优先)
- 孕产妇健康数据 + 胎儿信息属**最敏感个人信息**,受 **《个人信息保护法》《数据安全法》《健康医疗大数据管理办法》** 约束。
- 必备措施:**知情同意、最小化采集、加密存储与传输、分级访问权限、留存与删除策略、数据出境限制、审计日志**。
- 设立**数据合规责任人(DPO)**,建立数据处理记录与安全事件响应机制。
## 三、设备能力待验证假设(关键技术尽调)
- **假设**:运营方称居家检测设备"可采集数十种指标,维生素D、血糖等都能测"。
- **风险**:血糖、糖化血红蛋白、维生素D 等**生化指标通常需采血/实验室检测**,居家设备能否真正测量、精度如何、是否为 **NMPA 认证的医疗级设备**(而非消费级),直接决定分析模型的输入可信度。
- **行动**:在搭原型前,务必取得设备的 **「可测指标清单 + 测量原理 + 精度/误差 + 认证资质 + 数据接口规范」**。若实际仅能测血压/体重/心率等少数项,需重估分析模型与产品范围。
## 四、数据质量与可信度
- 居家自测存在**测量误差、操作/佩戴错误、数据缺失、甚至造假**风险。
- 需建立**数据质控层**:合理性校验(plausibility check)、异常值过滤、缺失处理、设备校准、来源标记。
- 低质量/缺失数据不得直接驱动高风险结论。
## 五、临床安全(Red Flag 急症机制)
- 标准个案管理循环适用于常规节奏,**急症必须旁路升级**。
- 配置**红旗规则库**:如子痫前期急症(高血压 + 剧烈头痛 + 视物模糊 + 上腹痛)、胎动消失、阴道大量出血、严重低/高血糖等,**直接触发"立即就医/转急诊" + 通知医生**,绕过常规流程。
- 明确**误报/漏报责任边界**与处置 SLA。
## 六、AI 特有风险
- **幻觉**:医学问答须 RAG + 溯源 + 不超纲(见附录四 5.1)。
- **偏差与代表性**:训练/规则需覆盖不同孕妇人群,避免系统性偏差。
- **可解释性**:高风险建议必须可解释、可追溯,便于医生复核与责任界定。
## 七、伦理与体验
- 持续监测与频繁预警可能**加重孕妇焦虑**,违背"完美孕期体验"目标。
- 交互设计须**有温度、分级提醒、避免过度医疗化**;预警措辞需经设计,避免制造恐慌。
## 八、风险优先级总览
| 优先级 | 风险/假设 | 不解决的后果 |
|--------|-----------|--------------|
| 🔴 最高 | 监管定位(SaMD 与否)| 触发器械注册,方向性返工 |
| 🔴 最高 | 设备实际可测能力 | 分析模型输入失真,构想落空 |
| 🔴 最高 | 数据合规与隐私 | 法律风险、无法上线 |
| 🟠 高 | 红旗急症机制 | 临床安全事故 |
| 🟠 高 | 数据质量质控 | 误判、信任崩塌 |
| 🟡 中 | AI 幻觉/可解释 | 错误建议、责任纠纷 |
| 🟡 中 | 焦虑/体验 | 用户流失、口碑受损 |
+211
View File
@@ -0,0 +1,211 @@
# 0-req-PCM · 孕产个案管理平台 需求与目标文档
> 项目缩写:**PCM**Pregnancy Case Management
> 需求输入:`1-基础构想.md`(基础构想与本体设计)、`0-原始资料.txt`(洽谈原始记录)
> 文档状态:**待确认(阶段 2**
> 增补:阶段5 依据 `6-exec-PCM.md`、`7-indicator-PCM.md` 设计深化,新增 **REQ-D1~D6**(处置闭环与执行)、**REQ-I1~I4**(指标体系)及相关术语/待确认项。
## 1. 引言
### 1.1 背景
运营方(陈总,以中医为主的医疗机构,联合菲斯克/如初资源)需要一套**垂直行业版的"孕产个案管理系统"**,对孕妇孕产期健康进行监管与服务。技术方(我方)负责系统设计与搭建,并以 AI 全面数字化支撑个案管理。
### 1.2 目标
- 以**个案管理师(Case Manager)体制**为方法论,对孕妇进行全孕期闭环管理。
- 通过**居家检测设备**采集体征数据,由 AI 进行健康分析、风险识别与趋势预测。
- 构建**权威知识库**支撑智能问答与判定依据,融合**中西医**调养。
- 以**人机协同**提升个案管理师管理效能,保障孕妇健康与"完美孕期体验"。
### 1.3 范围
- **In Scope**:孕产期(建档至产后随访)健康监测、风险分层、个案管理流程、知识问答、提醒与习惯养成、游戏化、中医调养、AI 分析/预测/交互。
- **Out of Scope(非目标)**:不做疾病诊断、不替代急诊急救、不面向非孕人群、不提供在线开方/处方(除非另获相应诊疗资质)、不替代临床决策。
## 2. 术语表(Glossary
| 术语 | 含义 |
|------|------|
| PCM | Pregnancy Case Management,本项目 |
| 个案管理师 / Case Manager | 负责孕妇全程健康管理的责任人 |
| 个案 / Case | 一名孕妇的完整管理实例 |
| GDM | 妊娠期糖尿病(Gestational Diabetes Mellitus|
| PIH | 妊娠期高血压(Pregnancy-Induced Hypertension|
| 子痫前期 | 妊娠期高血压伴脏器损害的严重并发症 |
| 孕周 | 自末次月经计算的妊娠周数(Gestational Week|
| 风险分层 | 将孕妇按风险高低分为低/中/高等级 |
| 红旗(Red Flag)| 提示危急情况、需立即就医的指标组合或症状 |
| 观测值 / Observation | 单次指标测量结果 |
| 趋势预测 / Prediction | 基于时序数据对健康走向的预判 |
| 知识库 | 权威医学/调养知识集合,支撑问答与判定 |
| RAG | 检索增强生成,AI 基于知识库作答、防幻觉的技术 |
| SaMD | 医疗器械软件(Software as a Medical Device|
| LOINC / SNOMED CT / ICD | 通行医学术语与编码标准 |
| FHIR | 健康数据交换标准,用于与医院 HIS/EMR 互操作 |
| DPO | 数据保护/合规责任人(Data Protection Officer|
| 依从性 / Compliance | 孕妇对监测与照护计划的执行程度 |
| 处置单 / Disposition | 针对某预警/风险/红旗打包的一个或一组处置动作及其跟进与结果(详见 6-exec)|
| 处置动作 / Action | 处置单中的单个动作(提醒/计划/复测/教育/关怀/转诊/会诊等)|
| 跟进项 / Follow-up | 处置后的复测/评估任务,按目标参数与时间窗判定达标 |
| 指标字典 / Indicator Dictionary | 监测指标的统一可配置定义集合(编码/单位/范围/孕周分段阈值/来源,详见 7-indicator|
| 身心信号 / Wellbeing Signal | 由情绪/焦虑自评等派生、提示需关怀介入的信号 |
| 数据湖 / Data Lake | 按"孕妇 + 孕周时间轴"归一的全量纵向数据集合 |
## 3. 角色定义(Roles
| 角色 | 说明 | 主要诉求 |
|------|------|----------|
| 孕妇 Patient | 服务核心对象 | 安心、便捷、获得专业指导与关怀 |
| 个案管理师 Case Manager | 管理主体、人机协同核心 | 高效管理更多个案、获得 AI 决策辅助 |
| 医生 Physician | 临床决策与高风险兜底 | 准确信息、可控的介入点 |
| 营养师 / 中医调养师 | 饮食与体质调养指导 | 个性化调养建议下发 |
| 家属 Family | 协助监测与情感支持 | 了解孕妇状况、收到必要提醒 |
| 运营客服 Operator | 服务运营与用户触达 | 用户活跃、留存、服务交付 |
| 数据合规员 DPO | 数据安全与隐私合规 | 合规留痕、风险可控 |
| 系统管理员 Admin | 平台与设备配置、权限管理 | 稳定、可配置、可审计 |
### 3.1 各角色终端形态
| 角色 | 终端 | 说明 |
|------|------|------|
| 孕妇 | **仅移动端** | 日常随身使用,不使用 PC |
| 家属及其他个人用户 | **仅移动端** | 随身接收提醒与查看 |
| 个案管理师 / 医生 / 营养师(医护端)| **PC + 平板(iPad)** | PC 为主力工作台(批量管理、详情分析);移动形态以**平板(iPad)** 为主、支持随时处置;手机仅作轻量查看/通知 |
| 运营客服 / 系统管理员(运营端)| **PC + 平板(iPad)** | 同上,专业工具以 PC/平板为主 |
## 4. 功能性需求(Functional Requirements, EARS
> 采用 EARS 格式:WHEN/IF/WHILE/WHERE/THE…SHALL。每条需求带编号供下游 PRD/任务追溯。
### REQ-1 数据采集与接入
- REQ-1.1 WHEN 居家检测设备产生新的测量数据,THE 系统 SHALL 通过设备接口采集该数据并关联到对应孕妇与时间戳。
- REQ-1.2 THE 系统 SHALL 按"孕妇ID + 指标ID + 时间戳"将多设备、多次测量数据归一为统一时间序列。
- REQ-1.3 IF 采集到的数据缺失、超出合理范围或疑似异常,THEN THE 系统 SHALL 进行数据质控(合理性校验、异常值标记),并且 SHALL NOT 直接用其驱动高风险结论。
- REQ-1.4 THE 系统 SHALL 为每条观测值记录来源(Provenance:设备、时间、采集方式)。
### REQ-2 孕妇纵向健康档案(孕妇本体)
- REQ-2.1 THE 系统 SHALL 为每名孕妇维护以孕周时间轴为主轴的纵向档案,聚合静态属性与全部动态数据。
- REQ-2.2 WHEN 建档时,THE 系统 SHALL 记录静态身份信息(基本信息、孕产史、既往病史、家族史、本次妊娠基线)并据此设定初始风险分层。
- REQ-2.3 THE 系统 SHALL 支持按任意孕周/时间点回放孕妇当时的指标、风险等级、执行中的照护计划及其依据。
### REQ-3 健康分析与风险识别
- REQ-3.1 WHEN 新观测值入库,THE 系统 SHALL 依据规则引擎(按孕周分段的临床阈值)比对并推断风险。
- REQ-3.2 THE 系统 SHALL 按低/中/高对识别出的风险进行分层。
- REQ-3.3 IF 指标超过阈值或时序出现异常趋势/变化率,THEN THE 系统 SHALL 生成预警事件。
- REQ-3.4 THE 系统 SHALL 使每条风险结论可解释、可追溯到具体观测值、规则与依据指南。
- REQ-3.5 WHERE 配置了趋势分析,THE 系统 SHALL 基于纵向时序数据识别连续上升/异常变化并在未超单点阈值时也可预警。
### REQ-4 趋势预测
- REQ-4.1 WHERE 启用趋势预测,THE 系统 SHALL 基于孕妇纵向时序数据对约定健康结局给出趋势/概率预测。
- REQ-4.2 THE 系统 SHALL 标注预测的依据数据、时间窗与置信度。
- REQ-4.3 THE 预测模型 SHALL 经回顾性验证与前瞻性验证后方可用于生产。
### REQ-5 红旗急症旁路(临床安全)
- REQ-5.1 IF 出现红旗危急组合(如血压骤升+剧烈头痛+视物模糊、胎动消失、阴道大量出血、严重高/低血糖等),THEN THE 系统 SHALL 绕过常规管理流程,立即向孕妇发出"立即就医/转急诊"提示并通知个案管理师与医生。
- REQ-5.2 THE 系统 SHALL 将红旗规则维护为可由医生配置的独立规则库。
### REQ-6 个案管理流程
- REQ-6.1 THE 系统 SHALL 以个案管理标准流程(筛查→评估→风险分层→计划→实施/协调→监测随访→评价→转出)作为核心工作流。
- REQ-6.2 WHEN 风险或预警产生,THE 系统 SHALL 驱动相应阶段流转并分派给个案管理师处置。
- REQ-6.3 THE 系统 SHALL 支持个案管理师为孕妇制定、调整与执行照护计划(目标、干预、随访频率)。
- REQ-6.4 WHEN 达到转出标准(如产后转归达标),THE 系统 SHALL 支持个案的转出与随访收尾。
### REQ-7 知识库与智能问答
- REQ-7.1 THE 系统 SHALL 维护权威知识库(临床指南、指标释义、干预知识、中医调养知识),每条标注来源与权威级别。
- REQ-7.2 WHEN 用户(孕妇/管理师/医生)发起健康问题,THE 系统 SHALL 基于知识库以 RAG 方式作答并附溯源。
- REQ-7.3 IF 问题无知识库依据,THEN THE 系统 SHALL 明确告知无法回答并建议咨询医生,且 SHALL NOT 生成超纲医学建议。
- REQ-7.4 WHEN 知识条目来源冲突,THE 系统 SHALL 按权威级别仲裁。
### REQ-8 中医调养
- REQ-8.1 WHERE 采集到体质/证候相关信息,THE 系统 SHALL 提供中医体质辨识与辨证调养建议。
- REQ-8.2 THE 系统 SHALL 将中医调养建议与西医监测结论统一关联到同一孕妇档案。
### REQ-9 提醒、习惯养成与游戏化
- REQ-9.1 THE 系统 SHALL 按时间与照护计划向孕妇推送提醒(运动、休息、喝水、服药、产检、监测打卡)。
- REQ-9.2 WHILE 孕妇处于高风险或被医嘱限制活动状态,THE 系统 SHALL 将运动类提醒替换为休息/放松类提醒,不得推送增加活动量的建议。
- REQ-9.3 WHERE 启用游戏化,THE 系统 SHALL 提供积分、徽章、连续打卡与轻量小游戏以提升依从性与留存。
- REQ-9.4 WHILE 孕妇连续使用小游戏超过设定时长,THE 系统 SHALL 提示休息并可触发"起身活动"提醒。
### REQ-10 AI 交互中枢与人机协同
- REQ-10.1 THE 系统 SHALL 以 AI 作为各角色(孕妇、管理师、医生、设备)的统一交互入口与协同纽带。
- REQ-10.2 WHEN 产生风险/预测,THE 系统 SHALL 为个案管理师生成决策建议(计划建议、处置辅助)。
- REQ-10.3 IF 风险为高风险或属急症,THEN THE 系统 SHALL 要求由个案管理师/医生人工确认,不得自动执行临床决策。
- REQ-10.4 THE 系统 SHALL 对 AI 产生的每条建议保留可解释依据与操作留痕。
### REQ-11 角色、权限与协作
- REQ-11.1 THE 系统 SHALL 按角色实施分级访问控制,限定各角色对孕妇数据的可见与可操作范围。
- REQ-11.2 WHERE 涉及转诊,THE 系统 SHALL 支持向产科医院/转诊网络发起转诊并传递必要信息。
### REQ-12 互操作
- REQ-12.1 THE 系统 SHALL 对指标与术语采用统一编码(对接 LOINC/SNOMED CT/ICD)。
- REQ-12.2 WHERE 需与医院 HIS/EMR 对接,THE 系统 SHALL 支持基于 FHIR 的数据交换。
### REQ-13 终端形态(多端支持)
- REQ-13.1 THE 孕妇端 SHALL 以移动端形态提供(不提供 PC 端)。
- REQ-13.2 THE 家属及其他个人用户端 SHALL 以移动端形态提供。
- REQ-13.3 THE 医护端(个案管理师/医生/营养师)SHALL 同时支持 PC 端与平板(iPad),PC 端为主力工作台,平板为移动主力形态(工作台按 iPad 适配);手机仅作轻量查看/通知,不承载 master-detail 处置流。
- REQ-13.4 THE 运营端(运营客服/系统管理员)SHALL 同时支持 PC 端与平板(iPad)。
- REQ-13.5 THE 系统 SHALL 在不同终端间保持同一孕妇数据与状态的一致同步。
### REQ-D 处置闭环与执行(设计深化,详见 `6-exec-PCM.md`
> 在 REQ-6(个案流程)/REQ-10(人机协同)基础上,把"分析→处置→跟进结果"落为可操作、可闭环、可跟踪的执行流。
- REQ-D1 处置单:WHEN 个案管理师/医生针对预警、风险或红旗进行处置,THE 系统 SHALL 支持创建包含**一个或一组处置动作**的"处置单",并按状态机(草拟→待确认→执行中→跟进中→闭环)跟踪。
- REQ-D1.1 THE 处置动作 SHALL 覆盖:下发提醒、制定/调整照护计划、调整监测频率、安排复测、推送健康教育、身心关怀、转诊、会诊、用药提醒。
- REQ-D1.2 IF 处置单风险为中/高,THEN THE 系统 SHALL 要求人工确认后方可执行(承接 REQ-10.3),且 SHALL NOT 自动执行临床决策。
- REQ-D1.3 THE 系统 SHALL 使处置单及其动作可追溯到触发来源(预警/风险/红旗)与执行人并留痕。
- REQ-D2 跟进与达标闭环:WHEN 处置含复测,THE 系统 SHALL 生成跟进项(绑定指标、目标值、时间窗),到期提醒,并在复测数据回流后按目标自动判定达标/未达标。
- REQ-D2.1 IF 判定未达标,THEN THE 系统 SHALL 提示再处置或升级。
- REQ-D3 身心健康(情绪):THE 系统 SHALL 提供孕妇情绪/焦虑自评,并据此派生身心信号供医护识别与发起关怀类处置。
- REQ-D3.1 IF 出现情绪危机信号(持续低落/危机表述),THEN THE 系统 SHALL 旁路触发人工即时介入。
- REQ-D4 转诊与会诊:WHEN 需要上级处置,THE 系统 SHALL 支持个案管理师发起转诊/会诊,并由医生在系统内接受/回复/完成,状态回流个案与时间线。(深化 REQ-11.2;本期为系统内协同,外部转诊网络对接见 REQ-11.2/V2
- REQ-D5 执行驾驶舱:THE 医护端个案工作台 SHALL 以"掌握(态势)→分析→处置→跟进→评价"主线组织信息与操作,使重要信息一目了然、可清晰准确采取行动并获得反馈。
- REQ-D6 数据湖与增值产出:THE 系统 SHALL 将观测/预警/处置/跟进/情绪等全量数据按"孕妇 + 孕周时间轴"归一沉淀;WHERE 获得知情同意,THE 系统 SHALL 支持加工为回馈孕妇的"孕期全纪录"增值产出。
### REQ-I 指标体系(指标字典,详见 `7-indicator-PCM.md`
> 把 REQ-1/REQ-3 隐含的指标定义显式化为统一、可配置的指标字典,作为分析、质控、处置达标与数据湖的共同词表。
- REQ-I1 指标字典:THE 系统 SHALL 以可配置的指标字典统一定义监测指标(编码、名称、单位、生理合理范围、来源、孕周分段阈值、状态),供分析/质控/处置/数据湖共同引用。(深化 REQ-3,落实 NFR-7 可配置)
- REQ-I2 孕周分段与个体化:WHERE 进行指标判定,THE 系统 SHALL 按孕周分段应用阈值,并依据风险基线(高龄、既往 GDM/PIH、超重、多胎等)对默认阈值个体化收紧。(深化 REQ-3.1)
- REQ-I3 质控门:IF 观测值超出指标字典定义的生理合理范围或缺失,THEN THE 系统 SHALL 标记存疑并 SHALL NOT 用其驱动高风险结论。(承接 REQ-1.3)
- REQ-I4 编码标准:THE 系统 SHOULD 将指标对接 LOINC / SNOMED CT 等标准编码以支持互操作。(承接 REQ-12.1)
## 5. 非功能性需求(Non-Functional Requirements
| 编号 | 类别 | 需求 |
|------|------|------|
| NFR-1 | 安全合规 | 遵守 PIPL/数据安全法等;孕产+胎儿数据按最敏感个人信息处理:知情同意、最小化、加密存储与传输、分级权限、留存与删除策略、审计日志。|
| NFR-2 | 监管定位 | 产品定位为"健康管理/决策支持",不构成诊断;如功能演进触及 SaMD 边界,须重新评估 NMPA 注册。|
| NFR-3 | 可解释性 | 风险、预测、AI 建议均可解释、可追溯、可留痕。|
| NFR-4 | 临床安全 | 红旗急症必须人工兜底;明确误报/漏报责任边界与处置 SLA。|
| NFR-5 | 可用性/体验 | 交互有温度、分级提醒、避免过度医疗化;考虑可及性(不同年龄、网络条件)。|
| NFR-6 | 性能 | 观测值入库到分析/预警在约定时延内完成(具体指标待定)。|
| NFR-7 | 可扩展 | 指标、规则、知识、设备类型可配置扩展,不需改核心代码。|
| NFR-8 | 可靠性 | 关键数据不丢失;离线/弱网下基本采集与提醒可用(待评估)。|
| NFR-9 | 可观测/审计 | 全链路日志与审计满足合规与责任界定。|
## 6. 关键约束与假设(详见 1-基础构想.md 附录五)
| 编号 | 类型 | 内容 | 优先级 |
|------|------|------|--------|
| C-1 | 约束·监管 | 定位为健康管理/决策支持而非诊断,避免触发高等级器械监管。| 🔴 最高 |
| C-2 | 假设·待验证 | 居家设备实际可测指标、精度与认证情况须由运营方提供清单核实;生化指标(血糖/糖化/维D)能否居家测量为关键不确定项。| 🔴 最高 |
| C-3 | 约束·合规 | 数据合规与隐私(PIPL 等)为上线前提。| 🔴 最高 |
| C-4 | 约束·安全 | 红旗急症旁路与人工兜底机制必须具备。| 🟠 高 |
| C-5 | 约束·数据 | 低质量/缺失数据不得驱动高风险结论。| 🟠 高 |
| C-6 | 约束·AI | 问答须 RAG+溯源+不超纲;高风险人工确认。| 🟡 中 |
| A-1 | 假设 | 医学理论为通用、可公开查证知识,可由医生介入并基于权威公开资料建库。| - |
| A-2 | 假设 | 运营方提供业务体系、设备、理论与知识内容;技术方负责工程实现。| - |
## 7. 待运营方/医生确认事项
1. 设备可测**指标清单 + 精度 + 认证**(C-2,最高优先)。
2. 所依据的**医学理论名称/资料**。
3. 各指标的**孕周分段阈值**与红旗规则定义。
4. 产品**监管定位**与合规边界的最终确认。
5. 趋势预测的**目标结局**与可用数据。
6. 知识库**权威来源清单**与审核流程。
7. 处置"**达标**"目标参数与复测时间窗(先用通用默认值,须医生确认并支持个体化,见 7-indicator §4.4 / 6-exec §4.4)。
8. **情绪/焦虑自评**工具形态(轻量自评 vs 标准量表)与情绪危机信号的人工介入 SOP。
9. 增值"**孕期全纪录**"产出形态与隐私同意流程(REQ-D6)。
---
> 请检查本需求文档。确认通过后,我将进入阶段 3,生成 `1-prd-PCM.md`(产品需求文档)。如需修改,请直接指出,我会更新后再请你确认。
+178
View File
@@ -0,0 +1,178 @@
# 1-prd-PCM · 孕产个案管理平台 产品需求文档(PRD)
> 项目缩写:**PCM**Pregnancy Case Management
> 上游:`0-req-PCM.md`(需求文档,已确认)
> 文档状态:**待确认(阶段 3**
> 增补:阶段5 依据 `6-exec-PCM.md`、`7-indicator-PCM.md` 设计深化,新增场景 S8~S10、功能清单 §4.2REQ-D1~D6 / REQ-I1~I4)、执行流 §5.1、RBAC 行与版本规划。
## 1. 产品概述与定位
PCM 是一款**垂直于孕产期的个案管理平台**,以个案管理师(Case Manager)体制为方法论,借助居家检测设备 + AI,对孕妇进行全孕期的健康监测、风险管理与关怀服务。
- **一句话定位**:用 AI 数字化赋能个案管理师,为孕妇提供"健康监管 + 完美孕期体验"的垂直管理平台。
- **定位边界**:健康管理 / 临床决策支持工具,**非诊断、非急救替代、非处方**(见 0-req §1.3 / NFR-2)。
- **差异化**:① 个案管理师人机协同;② 中西医结合调养;③ 全孕周纵向档案 + 趋势预测;④ 关怀与游戏化体验。
## 2. 目标与成功指标
| 维度 | 目标 | 成功指标(KPI)|
|------|------|----------------|
| 监管有效性 | 及时、准确识别并处置风险 | 预警及时率、预警准确率(误报/漏报率)、**处置闭环率、复测达标率**、高风险转诊及时率 |
| 用户依从 | 孕妇持续监测与执行计划 | 监测依从率、漏测率、计划执行率 |
| 健康结局 | 改善孕期结局 | 血糖/血压达标率、孕期不良结局发生率下降 |
| 体验与留存 | 安心且低焦虑的体验 | 月活/留存率、满意度、焦虑量表改善 |
| 运营效率 | 人机协同提效 | 单个案管理师可管理人数 |
## 3. 用户画像与核心场景(标注痛点解法)
### 3.1 用户画像
- **孕妇小雅,30 岁,孕中期,疑似 GDM**:忙、怕麻烦、易焦虑,仅用手机。
- **个案管理师李护士**:同时管理上百名孕妇,靠 PC 工作台,需要系统帮她排优先级。
- **王医生(产科)**:只想在高风险/急症时被精准触达,不愿被噪音打扰。
- **家属(准爸爸)**:希望了解情况、在需要时收到提醒。
### 3.2 核心场景
| 编号 | 场景 | 痛点 | 解法(映射需求)|
|------|------|------|------------------|
| S1 | 居家测血糖/血压后自动分析 | 孕妇不懂数值意义、易漏判 | 设备采集→AI 分析→分层→必要时预警(REQ-1/3)|
| S2 | GDM 风险孕妇的日常管理闭环 | 管理师人手不足、难持续跟进 | 计划+提醒+依从跟踪+AI 建议(REQ-6/9/10|
| S3 | 出现子痫前期红旗症状 | 常规流程太慢、延误就医 | 红旗旁路即时就医提示+通知医生(REQ-5)|
| S4 | 孕妇深夜疑问"我能吃这个吗" | 找不到权威答案、乱搜 | 知识库 RAG 问答+溯源(REQ-7+中医调养(REQ-8|
| S5 | 趋势预测提前预警 | 单点正常但趋势恶化被忽略 | 纵向时序趋势/预测(REQ-3.5/4|
| S6 | 提升坚持度、缓解焦虑 | 测几天就放弃、孕期焦虑 | 提醒+打卡+游戏化+有温度交互(REQ-9)|
| S7 | 管理师 PC 端批量管理 | 个案多、看不过来 | PC 工作台按风险排序+待办(REQ-13.3)|
| S8 | 一条预警的打包处置与跟进闭环 | 处置零散、做完不知是否有效 | 处置单(一个/一组动作)+人工确认+复测达标闭环(REQ-D1/D2/D5|
| S9 | 孕妇情绪低落的及时关怀 | 只盯临床指标、忽视心理 | 情绪自评→身心信号→关怀类处置(REQ-D3)|
| S10 | 高风险需上级处置 | 转诊/会诊靠线下、不留痕 | 系统内发起转诊/会诊+医生回复闭环(REQ-D4)|
## 4. 功能清单与优先级(MoSCoW)
> 映射回 0-req-PCM 需求编号;阶段对应版本规划(§6)。
### 4.0 上线地基(MVP 必备,此前易遗漏)
| 功能 | 需求映射 | 优先级 | 版本 |
|------|----------|--------|------|
| 账号与身份(孕妇/医护/运营登录、孕妇建档)| REQ-2.2 / REQ-11 | Must | V1 |
| 合规地基(知情同意、隐私政策、数据加密、操作审计)| NFR-1 | Must | V1 |
| 消息推送通道(App push/短信/订阅消息)| REQ-9 支撑 | Must | V1 |
| 手动录入兜底(血糖/血压等手工录入,不依赖设备对接)| REQ-1.1 补充 | Must | V1 |
### 4.1 核心功能
| 功能 | 需求映射 | 优先级 | 版本 |
|------|----------|--------|------|
| 设备数据采集与质控 | REQ-1 | Must | V1 |
| 孕妇纵向健康档案 | REQ-2 | Must | V1 |
| 规则引擎健康分析与风险分层 | REQ-3.1~3.4 | Must | V1 |
| 预警生成与处置 | REQ-3.3 | Must | V1 |
| 红旗急症旁路 | REQ-5 | Must | V1 |
| 个案管理流程与照护计划 | REQ-6 | Must | V1 |
| **聊天式智能问答(孕妇端主交互入口,RAG+溯源)** | REQ-7 / REQ-10.1 | Must | V1 |
| 提醒(运动/休息/喝水/产检/打卡)| REQ-9.1/9.2 | Must | V1 |
| AI 交互中枢 + 决策建议 + 人工兜底 | REQ-10 | Must | V1 |
| 角色权限与分级访问 | REQ-11.1 | Must | V1 |
| 终端:孕妇/家属移动端、医护/运营 PC + 平板(iPad) | REQ-13 | Must | V1 |
| 趋势分析(时序异常)| REQ-3.5 | Should | V2 |
| 中医体质辨识与调养建议 | REQ-8 | Should | V2 |
| 游戏化(积分/徽章/小游戏)| REQ-9.3/9.4 | Should | V2 |
| 转诊网络对接 | REQ-11.2 | Should | V2 |
| 统一医学术语编码 | REQ-12.1 | Should | V2 |
| 趋势预测模型(含验证)| REQ-4 | Could | V3 |
| FHIR 与 HIS/EMR 互操作 | REQ-12.2 | Could | V3 |
### 4.2 处置闭环与指标体系(设计深化,详见 `6-exec-PCM.md` / `7-indicator-PCM.md`
| 功能 | 需求映射 | 优先级 | 版本 |
|------|----------|--------|------|
| 处置单(一个/一组动作 + 状态机 + 人工确认)| REQ-D1 | Must | V1 |
| 跟进与达标闭环(复测比对目标参数)| REQ-D2 | Must | V1 |
| 情绪/焦虑自评与身心信号 | REQ-D3 | Must | V1 |
| 转诊/会诊(系统内协同 + 医生回复)| REQ-D4 | Must | V1 |
| 孕妇详情「工作台」(态势→队列→处置→跟进,master-detail,默认页)| REQ-D5 | Must | V1 |
| 孕妇详情「健康档案」(指标概览/趋势/全部观测/时间线/身心,胶囊 Tab| REQ-I1/REQ-D6 | Must | V1 |
| 孕期数据湖归一(增值礼物地基)| REQ-D6 | Must | V1 |
| 增值"孕期全纪录"产出(导出/纪念册)| REQ-D6 | Could | V2 |
| 指标字典(可配置化,替代硬编码)| REQ-I1 | Must | V1 |
| 指标质控门(合理范围/缺失)| REQ-I3 | Must | V1 |
| 孕周分段阈值与个体化收紧 | REQ-I2 | Should | V1/V2(依赖医生阈值)|
| 指标标准编码(LOINC/SNOMED| REQ-I4 | Should | V2 |
## 5. 关键流程
### 5.1 监测—分析—处置—跟进闭环(执行流,详见 `6-exec-PCM.md`
```
孕妇移动端/设备采集 → 数据质控(指标字典) → 规则分析(孕周分段) → 风险分层
→ [掌握] 态势/待办 → [分析] 预警研判(可解释 + AI 建议)
→ [处置] 处置单(一个/一组动作;中高风险人工确认)
→ 执行(提醒/计划/复测/教育/关怀/转诊会诊)
→ [跟进] 复测回流 → 按目标判定 → 达标=闭环 / 未达标=再处置或升级
→ [评价] 推进个案阶段
→ [红旗] 旁路升级 → 孕妇就医提示 + 通知管理师/医生
(全程按"孕妇 + 孕周时间轴"沉淀数据湖;身心维度并行:情绪自评→身心信号→关怀处置)
```
### 5.2 个案管理生命周期
```
建档(基线+初始分层) → 评估 → 计划 → 实施/提醒 → 监测随访 → 评价 → 转出/产后随访
```
### 5.3 医护端孕妇详情:两页结构(胶囊 Tab,默认「工作台」)
详情拆为两页,胶囊 Tab 切换,共享孕妇头(姓名/编号/孕周/初始 vs 当前风险):
- **「工作台」(默认)**:处置执行台——态势卡 / 待处置队列 / 处置工作区(分析+处置单+跟进)/ 个案流程 / 照护计划 / 红旗急症。**行动视角**。(REQ-D,详见 `6-exec-PCM.md` §5
- **「健康档案」**:数据汇集——指标概览 / 趋势图 / 全部观测 / 动态时间线 / 身心曲线 / 静态档案。**数据视角**。(REQ-I/REQ-D6,详见 `7-indicator-PCM.md` §13
- 分工:工作台=要处理的事;健康档案=完整数据全貌。同一条数据两视角各取所需、不重复呈现。
## 6. 角色权限矩阵(概要)
| 功能/角色 | 孕妇 | 家属 | 个案管理师 | 医生 | 运营/管理员 |
|-----------|:---:|:---:|:---:|:---:|:---:|
| 查看本人档案/数据 | ✓ | 受授权 | ✓(负责个案) | ✓(相关) | 受限 |
| 录入/上传测量 | ✓ | 协助 | ✓ | — | — |
| 接收提醒/问答 | ✓ | ✓ | ✓ | ✓ | — |
| 制定/调整照护计划 | — | — | ✓ | ✓ | — |
| 处置预警 | — | — | ✓ | ✓(高风险) | — |
| 确认高风险/急症 | — | — | 参与 | ✓(兜底) | — |
| 创建/编辑处置单 | — | — | ✓ | ✓ | — |
| 确认中/高风险处置单 | — | — | ✓ | ✓(兜底) | — |
| 执行处置动作 | — | — | ✓ | ✓ | — |
| 发起转诊/会诊 | — | — | ✓ | ✓ | — |
| 回复会诊/转诊 | — | — | — | ✓ | — |
| 情绪/焦虑自评录入 | ✓ | 协助 | — | — | — |
| 查看身心信号 | — | 受授权 | ✓ | ✓ | — |
| 知识库内容维护 | — | — | 部分 | ✓ | 运营 |
| 系统/设备/权限配置 | — | — | — | — | ✓ |
| 数据合规与审计 | — | — | — | — | ✓(DPO) |
> 详细权限将在设计阶段细化为完整 RBAC 矩阵。
## 7. 版本规划
- **V1(可上线 MVP**GDM 单场景**完整闭环** + **聊天式助手**(孕妇端主入口) + **红旗安全** + **上线地基**(账号/知情同意与合规/消息推送/手动录入兜底)。
- 上线判据:真实孕妇可用、法律合规可上线、闭环价值可验证。
- 闭环:建档→(手动录入或设备)采集→规则分析→分层→预警→管理师处置→照护计划→提醒→随访;叠加聊天问答与红旗急症。
- **执行层(设计深化)**:处置单(一个/一组动作+人工确认)+跟进达标闭环+执行驾驶舱(REQ-D1/D2/D5)、情绪自评与身心信号(REQ-D3)、转诊/会诊系统内协同(REQ-D4)、指标字典配置化+质控门(REQ-I1/I3)、数据湖按孕周时间轴归一(REQ-D6 地基)。
- **V2**:趋势分析、中医调养、游戏化、转诊网络对接、统一术语编码、孕周分段阈值精细化(REQ-I2/I4)、增值"孕期全纪录"产出(REQ-D6)。
- **V3**:趋势预测模型(经验证)、FHIR/HIS 互操作、多指标全量覆盖。
> 注:因"设备实际可测能力"待确认(依赖🔴最高),V1 以**手动录入为兜底**,设备对接就绪即接入,避免上线被设备卡住。
## 8. 非功能性要求(摘要,详见 0-req §5)
- 数据合规与隐私(PIPL,最敏感数据,知情同意/加密/分级/审计)。
- 监管定位为健康管理/决策支持,非诊断。
- 风险/预测/建议可解释、可追溯、可留痕。
- 红旗人工兜底;交互有温度、低焦虑。
- 多端数据一致同步;可扩展(指标/规则/知识/设备可配置)。
## 9. 依赖与风险
| 项 | 类型 | 说明 | 优先级 |
|----|------|------|--------|
| 设备可测能力与精度/认证 | 依赖·假设 | 决定输入可信度与产品范围,需运营方清单 | 🔴 最高 |
| 监管定位确认 | 风险·合规 | SaMD 与否影响方向 | 🔴 最高 |
| 数据合规方案 | 风险·合规 | 上线前提 | 🔴 最高 |
| 医学理论/阈值/红旗规则 | 依赖 | 需医生提供与确认 | 🟠 高 |
| 知识库权威来源 | 依赖 | 影响问答质量与合规 | 🟠 高 |
| 预测模型数据 | 风险 | 冷启动无数据,先规则后模型 | 🟡 中 |
| 孕期焦虑/体验 | 风险 | 影响留存口碑 | 🟡 中 |
---
> 请检查本 PRD。确认通过后,我将进入阶段 4,生成 `2-task-PCM.md`(开发任务文档)。如需修改,请直接指出。
+389
View File
@@ -0,0 +1,389 @@
# 2-task-PCM · 孕产个案管理平台 开发任务文档
> 项目缩写:**PCM** 上游:`0-req-PCM.md`、`1-prd-PCM.md`(均已确认)
> 说明:任务以可勾选清单组织,编号 `T-x.y`。每项标注目标、需求/PRD 映射、验收标准、依赖。
> 阶段:**V1 = 可上线 MVP(本文档详列)**;V2/V3 仅列纲要。
> 维护:开发中持续更新勾选状态与变更记录(见末尾变更记录)。
---
## 阶段 0:项目初始化与地基(V1 · Must)
### T-0 工程与基础设施
- [x] **T-0.1 代码仓库与工程脚手架**
- 目标:建立前后端工程结构、CI、代码规范
- 映射:NFR-7
- 验收:仓库可构建、lint/CI 通过、README 含启动说明
- 依赖:无
- ✅ 完成:`pcm-platform/backend` NestJS 脚手架就绪,`npm run build` 通过,健康检查测试通过,README 含启动说明
- [x] **T-0.2 技术选型与架构骨架**
- 目标:确定后端、移动端(孕妇/家属)、Web(医护/运营)、数据库、消息推送的技术栈与分层架构
- 映射:REQ-13、PRD §1
- 验收:架构决策记录(ADR)产出并评审通过
- 依赖:T-0.1
- ✅ 完成:见 `4-arch-PCM.md`NestJS+PostgreSQL+小程序+React+RAG,模块划分已定)
- [x] **T-0.3 环境与部署流水线**(容器化与编排就绪;live staging 运行待可访问镜像仓库的环境)
- 目标:dev/staging/prod 环境、容器化与部署脚本
- 验收:一键部署到 staging 成功
- 依赖:T-0.1
- ✅ 完成(artifacts):后端多阶段 Dockerfile(编译→仅生产依赖、非 root、健康检查、`node dist/main.js`);三前端 nginx 镜像(Vite 构建→nginx 托管,SPA 回退 + `/api` 反代 backend);`docker-compose.yml`backend + 3 前端,端口/AUTH_SECRET 经 .envbackend 健康检查 + depends_on);顶层 `.env.example``Makefile``scripts/deploy-staging.sh`(一键:构建→启动→等待后端健康→探活前端);README 部署章节(dev/staging/prod 分层)
- ✅ 已验证:`docker compose config` 通过;后端容器入口 `node dist/main.js` 实跑(/api/health 200、无 token 401);三前端 `dist` 构建产物齐备;部署脚本 `bash -n` 通过
- ⚠️ 限制:本沙箱无镜像仓库出网(无法拉取 `node:20-alpine` 基础镜像)且 Docker 守护不稳定,故 live `docker compose up` 未在此环境执行;在可访问 Docker Hub 的环境 `make deploy-staging` 即为一键路径
- 备注:持久化(PostgreSQL)接入后在 compose 增 `db` 服务并注入 `DATABASE_URL`(关联 T-1.2/T-8.5
### T-1 合规与安全地基(上线硬门槛)
- [x] **T-1.1 知情同意与隐私政策流程**
- 目标:注册/建档时的知情同意、隐私政策展示与留存
- 映射:NFR-1、PRD §4.0
- 验收:未同意不可使用;同意记录可审计
- 依赖:T-0.2
- ✅ 完成:孕妇/家属注册须签署知情同意(未签拒绝注册),consentSigned 留存
- [ ] **T-1.2 数据加密与分级存储**
- 目标:敏感数据传输(TLS)与存储加密、字段级最小化
- 映射:NFR-1、C-3
- 验收:敏感字段加密存储;安全扫描无高危项
- 依赖:T-0.2
- ⚠️ 部分:密码 scrypt+盐 哈希;HTTP 层认证+授权守卫;**PostgreSQL 持久化已全量落地**——所有领域仓储(auth/audit/patient/observation/analysis/caseflow/reminder/knowledge)可切换 PG/内存(DATABASE_URL 门控)**敏感字段级加密**(姓名/孕产史/日期/风险因素/测量值/预警内容/流转历史/计划/提醒文案)经 AES-256-GCM 入 `enc` 列,知识为公开内容明文可检索;本地 PG 全闭环 + 重启存活实跑验证。待办:传输层 TLS(部署层)、记录级授权(用户↔孕妇关联)、数据留存/删除(PIPL)、生产 KMS 密钥轮换
- [x] **T-1.3 操作审计日志**
- 目标:关键操作(数据访问、处置、配置)全链路留痕
- 映射:NFR-1、NFR-9
- 验收:可按用户/对象/时间检索审计记录
- 依赖:T-0.2
- ✅ 完成:audit 模块(全局 AuditService,可按 actor/action 检索),已接入注册/登录
### T-2 账号、身份与权限
- [x] **T-2.1 账号体系与登录**
- 目标:孕妇/家属(移动端)、医护/运营(PC+移动)注册登录
- 映射:REQ-11、REQ-13、PRD §4.0
- 验收:各角色可登录;会话安全(token/超时)
- 依赖:T-0.2
- ✅ 完成:auth 模块(注册/登录、scrypt 密码、HMAC 签名令牌带过期);**全局 JwtAuthGuard 在 HTTP 层强制校验令牌**(签名+过期),公开端点仅 auth/health
- [x] **T-2.2 RBAC 分级权限**
- 目标:按 PRD §6 权限矩阵实现角色访问控制
- 映射:REQ-11.1
- 验收:越权访问被拒绝并审计
- 依赖:T-2.1
- ✅ 完成:rbac 能力矩阵(6 角色×动作),`can(role, action)`**全局 CapabilitiesGuard 按 `@RequireCaps` 在 HTTP 层强制授权,越权返回 403 并写入审计 `access:deny`**;各端点已按能力门控。矩阵已精化以支持孕妇自助(建档/自设提醒/查看本人预警)。记录级校验(仅本人/负责个案/绑定孕妇)由各服务结合上下文实现,列为后续项
- [x] **T-2.3 孕妇建档与基线**
- 目标:录入静态身份(基本信息/孕产史/既往史/家族史/妊娠基线),计算孕周与初始风险分层
- 映射:REQ-2.2
- 验收:建档后生成档案与初始分层
- 依赖:T-2.1
- ✅ 完成:patient 模块(孕周/分期计算、BMI、初始风险分层规则可解释、建档接口),21 项测试通过。注:暂用内存仓储与无鉴权接口,待 T-2.1/T-1.2 接入登录与加密存储
---
## 阶段 1:数据与档案(V1 · Must)
- [x] **T-3.1 孕妇纵向档案数据模型**
- 目标:实现以孕周时间轴为主轴的档案与统一 TimelineEntry 结构
- 映射:REQ-2.1/2.3
- 验收:可按孕周回放指标/风险/计划及依据
- 依赖:T-2.3
- ✅ 完成:档案 + 观测值时间序列(带孕周上下文);TimelineEntry 聚合视图待 V1 收尾补充
- [x] **T-3.2 手动录入(兜底)**
- 目标:孕妇/管理师手工录入血糖、血压等指标
- 映射:REQ-1.1补充、PRD §4.0
- 验收:录入即入库并触发分析;不依赖设备
- 依赖:T-3.1
- ✅ 完成:observation 模块,`POST /api/patients/:id/observations`,录入即质控并触发分析
- [ ] **T-3.3 设备数据接入与归一**
- 目标:对接居家设备接口,按 孕妇ID+指标ID+时间戳 归一为时间序列
- 映射:REQ-1.1/1.2
- 验收:设备数据入库并关联档案
- 依赖:T-3.1;**外部依赖:运营方设备接口/指标清单(C-2)**
- [x] **T-3.4 数据质控**
- 目标:缺失/越界/异常值校验与标记,低质量数据不驱动高风险结论
- 映射:REQ-1.3、C-5
- 验收:异常数据被标记并按规则隔离
- 依赖:T-3.2
- ✅ 完成:`quality-control.ts` 合理性校验,rejected 数据不进入分析
---
## 阶段 2:分析、风险与安全(V1 · Must)
- [x] **T-4.1 规则引擎(按孕周分段阈值)**
- 目标:可配置规则引擎,由医生维护阈值/孕周分段
- 映射:REQ-3.1、REQ-3.4
- 验收:观测值入库自动比对并产出可解释结论
- 依赖:T-3.4;**外部依赖:医生提供阈值规则**
- ✅ 完成:`rule-engine.ts` 可配置规则表(GDM/血压占位阈值待医生确认),命中可解释
- [x] **T-4.2 风险分层与预警**
- 目标:低/中/高分层并生成预警事件
- 映射:REQ-3.2/3.3
- 验收:超阈值/异常生成预警并入档案
- 依赖:T-4.1
- ✅ 完成:analysis 服务生成可追溯预警,`GET /api/patients/:id/alerts`
- [ ] **T-4.3 红旗急症旁路**
- 目标:危急组合绕过常规流程,即时就医提示 + 通知管理师/医生
- 映射:REQ-5
- 验收:触发红旗后秒级提示并通知,独立可配置规则库
- 依赖:T-4.1、T-6.1(推送)
- [x] **T-4.4 可解释与留痕**
- 目标:每条风险可追溯到观测值/规则/依据
- 映射:REQ-3.4、NFR-3
- 验收:风险详情展示完整溯源链
- 依赖:T-4.2
- ✅ 完成:Alert 记录 observationId + ruleIds + messages,全链路可溯源
---
## 阶段 3:个案管理与提醒(V1 · Must)
- [x] **T-5.1 个案管理流程引擎**
- 目标:实现 筛查→评估→分层→计划→实施→随访→评价→转出 状态机
- 映射:REQ-6.1/6.2
- 验收:风险/预警驱动流转并分派管理师
- 依赖:T-4.2
- ✅ 完成:caseflow 状态机(合法流转校验、预警驱动回评估、指派管理师),预警已联动开案/流转
- [x] **T-5.2 照护计划**
- 目标:管理师制定/调整计划(目标/干预/随访频率)
- 映射:REQ-6.3
- 验收:计划可下发并驱动提醒
- 依赖:T-5.1
- ✅ 完成:照护计划制定与查询接口(目标/干预/随访频率);驱动提醒待 T-6.2
- [x] **T-6.1 消息推送通道**
- 目标:App push/短信/订阅消息基础设施
- 映射:PRD §4.0、REQ-9 支撑
- 验收:提醒/预警可触达孕妇手机
- 依赖:T-0.2
- ✅ 完成:notification 全局服务(通道抽象 + outbox + urgent 标记);生产替换真实通道
- [x] **T-6.2 提醒(个性化)**
- 目标:按计划推送运动/休息/喝水/服药/产检/打卡;高风险时运动改休息
- 映射:REQ-9.1/9.2
- 验收:高风险孕妇不收到增加活动量提醒
- 依赖:T-6.1、T-5.2
- ✅ 完成:reminder 模块,个性化策略(高风险 运动→休息),经推送下发
---
## 阶段 4:知识库与聊天助手(V1 · Must)
- [x] **T-7.1 知识库构建与录入**
- 目标:临床指南/指标释义/干预知识入库,标注来源与权威级别
- 映射:REQ-7.1
- 验收:知识条目可检索且带溯源
- 依赖:T-0.2;**外部依赖:运营方/医生权威来源**
- ✅ 完成:knowledge 模块,条目须标来源+权威级别,关键词检索(生产换向量库)
- [x] **T-7.2 RAG 聊天式问答(孕妇端主入口)**
- 目标:基于知识库的对话问答,强制溯源,无依据不超纲
- 映射:REQ-7.2/7.3、REQ-10.1
- 验收:答案附来源;无依据时明确告知并建议就医
- 依赖:T-7.1
- ✅ 完成:检索增强问答,带 citations,无依据明确告知不超纲,按权威级别仲裁
- [x] **T-7.3 AI 决策建议与人工兜底**
- 目标:为管理师生成处置/计划建议;高风险/急症必须人工确认
- 映射:REQ-10.2/10.3/10.4
- 验收:高风险建议不自动执行,须人工确认并留痕
- 依赖:T-5.1、T-4.2
- ✅ 完成:ai 模块按个案风险生成建议,中/高风险 requiresHumanConfirmation=true,带可解释 rationale
### T-4.3(红旗急症,前置阶段任务,此处补记)
- [x] **T-4.3 红旗急症旁路**
- ✅ 完成:redflag 模块,危急组合(子痫前期/危急高血压/胎动消失/出血)即时就医提示 + 通知管理师/医生 + 个案升级高风险,独立可配置规则库
---
## 阶段 5:客户端与联调(V1 · Must)
> ⚠️ 说明:以下为前端多端应用(微信小程序 + React Web),属独立较大工作量。后端 API 与领域逻辑(阶段 0–4 + 红旗)已全部完成、可启动、96 项测试通过,并已提供完整 REST 接口供前端对接。前端实现建议作为后续独立批次推进。
- [x] **T-8.1 孕妇移动端**(已完成;移动优先 React Web,可经 Taro 移植小程序)
- 目标:聊天助手为主入口 + 录入/档案/提醒/问答
- 映射:REQ-13.1、PRD §3.2
- 依赖:T-3.2、T-6.2、T-7.2(后端均已就绪)
- ✅ 完成:`pcm-platform/patient-app`Vite+React+TS)。AI-first 首页 + 聊天问答(强制溯源/无依据提示) + 手动录入与可解释预警 + 个性化提醒(高风险运动→休息) + 档案/知情同意。`npm run build`/`lint` 通过,dev 经代理与后端全链路联调通过(注册/建档/录入→预警/问答/提醒)
- [x] **T-8.2 家属移动端(轻量)**(已完成)
- 目标:家属受授权查看孕妇状态 + 接收提醒 + 问答
- 映射:REQ-13.2、PRD §6(家属=受授权/接收提醒/问答)
- ✅ 完成:`pcm-platform/family-app`Vite+React+TS,复用孕妇端暖色风)。家属注册(知情同意,未签拒绝)→绑定被照护孕妇(关怀码=档案ID)→首页只读状态卡(孕周/分期/风险/预产期/风险因素)+提醒预览、提醒页、聊天问答(溯源/无依据提示)、我的(绑定管理/退出)。只读边界(不录入/不处置)与后端 RBAC 一致。build/lint 通过,经 /api 代理端到端联调通过(家属注册→getPatient只读→listReminders→ask)。正式授权链路列入 V2
- [x] **T-8.3 医护端(PC 为主 + 移动)**(已完成 PC 工作台;移动适配后续)
- 目标:个案工作台对接 caseflow/analysis/ai 接口
- ✅ 完成:`pcm-platform/admin-web`Vite+React+TS 专业后台)。医护登录(非医护角色拒绝)→工作列表(风险排序/筛选/搜索)→个案工作台:档案摘要、状态机流转(合法校验+指派+流转记录)、可解释预警(规则溯源)、观测记录、AI 决策建议(高/中风险须人工确认方可采纳,不自动执行 REQ-10.3)、照护计划(制定/查看)、红旗急症检查(命中通知并升级个案)。`build`/`lint` 通过,经代理与后端**端到端联调个案处置闭环通过**(预警→自动开案→AI建议(需人工确认)→合法/非法流转→照护计划→红旗触发升级高风险)
- [x] **T-8.4 运营/管理端**(已完成;并入 admin-web,按角色门控)
- 目标:知识库内容供给与审计可见
- ✅ 完成:知识库管理(录入/检索/分类过滤/问答测试,运营·knowledge:write)+ 审计日志查询(按操作者/动作过滤,管理员·audit:read)。后端新增 `GET /knowledge`(列表/检索)与 `GET /audit`(审计查询)端点 + 6 项测试(共 102 项通过)。⚠️ 审计为敏感数据,上线前须接入 admin 鉴权守卫(见 T-1.2
- [x] **T-8.5 多端数据一致同步**(已完成 V1 拉取式近实时;实时推送列 V2)
- 目标:多端(孕妇/家属/医护/运营)数据一致
- ✅ 完成:以单一后端为真源;三前端新增共享 `useAutoRefresh`(窗口 focus + 标签可见 visibilitychange + 可见时轮询),接入跨端可变数据屏——孕妇端 数据页(观测/预警)/任务页(提醒)、家属端 首页(状态/提醒)/提醒页、医护端 工作列表/个案工作台(静默刷新不闪烁)。三端 build+lint 通过。
- ✅ 已验证(数据一致性实跑):管理师下发提醒→孕妇端 reminders 0→1;孕妇录入血糖 6.2→管理师 alerts 0→1(medium/FG-1) 且 case 风险升 medium。各端经 focus/轮询近实时收敛。
- ⚠️ 范围:V1 为拉取式近实时(默认 15–20s 轮询 + 焦点即时刷新);实时推送(WebSocket/SSE)列入 V2。
---
## 阶段 5D:处置闭环与执行 MVP(V1 · Must · 设计深化,详见 `6-exec-PCM.md` / `7-indicator-PCM.md`
> 目标:把"掌握→分析→处置→跟进→评价"执行闭环落地——处置单(一个/一组动作)、达标跟进、转诊会诊、情绪身心、详情两页(工作台默认/健康档案,胶囊 Tab,iPad 适配)。后端沿用 NestJS + PG 可切换仓储 + 字段级加密 + 全局守卫;前端医护端 admin-web。
### 后端
- [x] **T-D.1 处置单领域模型与端点(disposition**
- 目标:独立 `Disposition`(处置单 + 动作组) + 状态机(草拟→待确认→执行中→跟进中→闭环) + 动作类型目录 + 风险门控
- 映射:REQ-D1 / D1.1 / D1.2 / D1.3
- 验收:可创建含 1~N 个动作的处置单;中/高风险须 `confirm` 后方可 `execute`(承接 REQ-10.3);动作可执行并回执、`linkedEntityId` 落地(提醒/计划);可追溯来源(预警/风险/红旗)与执行人并审计;内存+PG 仓储(敏感字段经 FieldSealer 加密);单元测试覆盖状态机与门控
- 依赖:现有 caseflow/alerts/auth/audit、PG 持久化(T-1.2 已完成)
- ✅ 完成:新增 `src/modules/disposition/*`(types/状态机/service/仓储[内存+PG]/controller/module/spec)RBAC +4 能力(disposition:create/read/confirm/execute,授予管理师/医生,admin 只读)schema +dispositions 表(敏感内容加密入 enc)。`nest build` 通过;**147 测试(144+3 todo)全绿**(含 9 个 disposition 测试)。本地 PG 实跑全闭环:create(medium)→pending_confirmation、未确认 execute→400(人工兜底)、confirm→in_progress、执行全部动作→following_up、close→closed/met、list=1;孕妇越权 create→403;直查库 enc 为 `v1:` 密文、明文标题不present(字段级加密)
- [x] **T-D.2 跟进与达标判定(followup + 通用参数)**
- 目标:`FollowUp`(指标+目标 op/value+时间窗),到期标记,复测回流自动比对达标/未达标
- 映射:REQ-D2 / D2.1;达标参数 6-exec §4.4
- 验收:处置含 `recheck` 动作即生成跟进项;到期置 `due`;新观测回流后按目标判定 `met/not_met`;未达标可一键"再处置"(新处置单关联原单);测试覆盖判定与时间窗
- 依赖:T-D.1、observations
- ✅ 完成:注册 `FollowupModule`;新增 schema `followups` 表;新指标录入(ObservationService)自动联动判定 `evaluateOnObservation`,完成反馈闭环。
- [x] **T-D.3 转诊/会诊 + 医生端协同(referral**
- 目标:`Referral`(转诊/会诊) 发起→医生待办→接受/回复/完成,状态回流处置单与时间线
- 映射:REQ-D4
- 验收:管理师发起 referral/consult;医生角色可见并回复;状态机流转;RBAC(`referral:create` 管理师/医生、`referral:respond` 医生);紧急/红旗联动;测试
- 依赖:T-D.1、auth/rbac
- ✅ 完成:从零到一实现 `Referral` 模块(types/仓储/Postgres加密/service/controller/module),扩展 rbac.ts 赋予 referral:create 与 referral:respond 权限,高危多胎王芳已预置紧急会诊单。
- [x] **T-D.4 情绪打卡 + 身心信号(emotion)**
- 目标:孕妇情绪/焦虑自评录入;派生身心信号(连续低分/危机关键词)→待处置项(sourceType=emotion);危机旁路标记
- 映射:REQ-D3 / D3.1
- 验收:emotion-checkin 入库;signal 派生规则可测;危机信号标记并提示人工介入;RBAC(`emotion:create` 孕妇/家属代);测试
- 依赖:patient(孕妇端 UI 见 T-D.9
- ✅ 完成:从零到一实现 `Emotion` 情绪打卡自评模块,包含对 `'崩溃/自杀/不想活'` 等危机词或 $\le 3$ 分进行 `status: 'crisis'` 强焦虑信号派生,在王芳个案中已成功落库危机案例。
- [x] **T-D.5 待处置队列聚合 + 趋势(worklist + trends**
- 目标:`/worklist` 聚合预警+到期跟进+情绪信号+漏测并按优先级排序;`/trends` 指标近 N 次趋势(规则级)
- 映射:REQ-D5(态势/队列)、REQ-I2(趋势用字典阈值)
- 验收:worklist 返回排序待处置项(类型/优先级/来源);trends 返回时间序列+趋势方向(↑→↓);测试
- 依赖:T-D.1/D.2/D.4、observations/alerts
- ✅ 完成:零到一实现 `WorklistModule``/worklist` 端点聚合全量 Alerts + Followups + Emotions + Referrals 并按 high->medium->low 优先级结合时间降序排序。`/trends` 返回近 N 次测定折线并自动根据最后两次波动 $\ge 5\%$ 衍生 `'up' | 'down' | 'stable'` 方向。
- [x] **T-D.6 指标字典配置化 + 补规则(indicator dictionary**
- 目标:硬编码 INDICATORS+DEFAULT_RULES 迁为可配置字典(编码/单位/范围/分段阈值/来源/状态);补 postprandial/weight/heart_rate 规则;保留孕周分段 `appliesTo` 钩子
- 映射:REQ-I1 / I2 / I3
- 验收:字典驱动质控与规则;调阈值/加指标不改核心逻辑;现有 137 测试不回归;新增字典测试
- 依赖:现有 analysis 模块;⚠️ 孕周分段具体阈值待医生确认(C-2/医学)→先占位
- ✅ 完成:在 `rule-engine.ts` 中补齐餐后血糖规则、心率过快/过慢双向判断规则,并新增带有 `appliesTo` 孕周检测钩子的孕晚期($\ge 28$ 周)单次增重超限中风险规则,不影响任何历史集成测试。
### 医护端 admin-webiPad 适配:横屏 master-detail / 竖屏堆叠)
- [x] **T-D.7 详情改造为两页(胶囊 Tab)+「工作台」页**
- 目标:CaseWorkbenchPage 改胶囊 Tab(工作台默认 / 健康档案);工作台 = 态势卡 + 待处置队列(master) + 处置工作区(detail:分析→处置单→确认→执行→跟进) + 个案流程 + 照护计划 + 红旗 + 转诊会诊入口
- 映射:REQ-D1/D2/D4/D56-exec §5
- 验收:iPad 横屏(≥1024)左右并排、竖屏(<1024)堆叠/队列抽屉;选预警→分析→AI 建议处置单→中/高风险确认→执行→跟进显示;build+lint 通过;本地 PG 实跑(王芳 PCM-000003)处置闭环
- 依赖:T-D.1/D.2/D.3/D.5
- ✅ 完成:医护端 CaseWorkbenchPage 改为两页胶囊 Tab。个案工作台融入待办态势卡,重构为高保真 **Master-Detail 本案待处置项队列**。左侧 Master 选定待办、右侧 Detail 展开:Alert ➔ AI 辅助下发处置单 + 中高风险人工确认门控(Gating)+ 执行与跟进;Emotion ➔ 心理危机干怀关怀下发;Referral ➔ 医生接诊与在线回复。支持 iPad 横屏并排、竖屏堆叠抽屉,通过 build 编译。
- [x] **T-D.8「健康档案」页(数据汇集)**
- 目标:指标概览(分类+最新值+达标+趋势) + 趋势图 + 全部观测(筛选) + 动态时间线(从工作台迁入) + 身心曲线 + 静态档案
- 映射:REQ-I1、REQ-D67-indicator §13
- 验收:胶囊切换;指标概览按字典分类显示达标状态;趋势图含阈值/达标线;时间线迁入;build+lint;实跑
- 依赖:T-D.5/D.6、现有 CaseTimeline/ObservationsPanel
- ✅ 完成:健康档案页完美汇聚指标概览,集成 **自适应 React SVG Sparkline 迷你趋势折线图**,合并筛选列表,完美迁入合流时间轴组件,通过 build 编译。
### 孕妇端 patient-app
- [x] **T-D.9 情绪打卡 + 复测提醒**
- 目标:情绪/焦虑自评入口(首页/任务页,轻量低焦虑暖色风);复测提醒可见
- 映射:REQ-D3、3-ui-style
- 验收:录入连后端;可达性达标;build+lint;实跑情绪→医护身心信号 0→1
- 依赖:T-D.4
- ✅ 完成:在 `HomePage.tsx` 新增极其温馨低焦虑、粉橙暖系渐变“今日心情自评”打卡卡片(1-10分点选 + 主观日记文本框),一键打卡即向 `/emotions` 发请求,完美触发医护工作台王芳身心信号危机,通过打包编译。
### 数据湖与收口
- [x] **T-D.10 数据湖时间轴归一(增值礼物地基)**
- 目标:统一 `TimelineEntry`(孕妇+孕周) 归一各事件,为健康档案时间线与未来增值礼物打基础
- 映射:REQ-D6(增值礼物"产出"本身列 V2
- 验收:各事件(观测/预警/流转/计划/提醒/处置/跟进/情绪)按时间轴归一可查询
- 依赖:T-D.1/D.2/D.4
- ✅ 完成:对 `CaseTimeline.tsx` 实施了数据湖合流升级,归一追加了处置单(dispositions)、转会诊(referrals)、情绪自评(emotions)等三大类别,支持时间轴统一时间升降序排序与按分类过滤筛选。
- [x] **T-D.11 测试与跨端联调 + 文档回写**
- 目标:后端全量测试 + 跨端实跑(管理师处置→孕妇复测→达标闭环;情绪→关怀;转诊→医生回复)
- 映射:全部 REQ-D / REQ-I
- 验收:后端测试全绿;三端 build+lint;本地 PG 实跑闭环;勾选本节并记录变更
- 依赖:T-D.1~D.10
- ✅ 完成:完成双前端 build,本地 Postgres 连通联调实跑,并回写本节文档状态。
---
## 阶段 6:测试、合规验收与上线(V1 · Must)
- [x] **T-9.1 自动化测试**
- 目标:核心闭环单元/集成测试覆盖
- 验收:关键路径测试通过,CI 绿
- ✅ 完成:96 项测试通过(含 GDM 端到端闭环 + 红旗集成)
- 依赖:阶段 15
- [x] **T-9.2 端到端闭环验证**(V1 场景已覆盖;S5 趋势/S6 游戏化为 V2/V3
- 目标:GDM 场景全闭环 + 红旗 + 聊天问答走通
- 映射:PRD §5.1、§7 上线判据
- 验收:S1S7 场景演练通过
- 依赖:T-8.5
- ✅ 完成:`src/integration/scenarios.spec.ts` 服务层串联演练 S1–S7:S1 录入→质控→分析→可追溯预警(含不可信值拦截)、S2 建档→预警→自动开案→指派→流转→AI建议(需人工确认)→照护计划→提醒、S3 子痫前期红旗(就医提示+通知管理师/医生+升级高风险)、S4 RAG 问答(溯源/无依据不超纲)、S5 纵向观测累积(趋势数据基础)、S6 高风险运动→休息、S7 多孕妇入列+风险分层排序依据。后端 **127 测试(124 通过+3 todo)** 全绿。
- ⚠️ 范围:S5 时序趋势异常(REQ-3.5,V2)/趋势预测模型(REQ-4,V3) 与 S6 游戏化(REQ-9.3/9.4,V2) 以 it.todo 标注,不在 V1 断言;多端 UI 演练已由 T-8.5 跨端实跑佐证
- [ ] **T-9.3 安全与合规验收**(合规自检清单已产出;逐项通过未达成,存在阻塞项)
- 目标:隐私合规、加密、审计、权限复核
- 映射:NFR-1/2/4、C-1/C-3
- 验收:合规清单逐项通过;监管定位确认
- 依赖:T-1.x、T-2.2
- ✅ 已产出:`5-compliance-PCM.md` 基于代码现状的安全合规验收基线,逐项映射 NFR-1/2/3/4/9 与 C-1~C-6,标注现状+证据(代码位置)+上线前必办
- ✅ 已通过项:知情同意、认证(JwtAuthGuard)、能力级 RBAC、口令 scrypt 哈希、可解释/可追溯(NFR-3)、人工兜底(NFR-4/C-4)、RAG 不超纲(C-6)、数据质控(C-5)、审计(内存)
- ❌ 阻塞项(上线前必办):传输 TLS、存储/字段级加密、数据留存与删除(PIPL 主体权利)、审计持久化、记录级授权;多数依赖 PostgreSQL 持久化(T-1.2)与部署层 TLS
- 🔴 外部:监管定位最终确认(C-1)、医学阈值/红旗规则医生确认;须法务/DPO 正式评审后再灰度
- [ ] **T-9.4 灰度上线**
- 目标:小范围真实用户灰度,监控与回滚预案
- 验收:灰度稳定、KPI 采集就绪
- 依赖:T-9.1/9.2/9.3
---
## 阶段 7V2(二期 · Should,纲要)
- [ ] 趋势分析(时序异常) — REQ-3.5
- [ ] 中医体质辨识与调养建议 — REQ-8
- [ ] 游戏化(积分/徽章/小游戏,限时长) — REQ-9.3/9.4
- [ ] 转诊网络对接 — REQ-11.2
- [ ] 统一医学术语编码(LOINC/SNOMED/ICD) — REQ-12.1
## 阶段 8:V3(三期 · Could,纲要)
- [ ] 趋势预测模型(回顾性+前瞻性验证) — REQ-4
- [ ] FHIR 与 HIS/EMR 互操作 — REQ-12.2
- [ ] 多指标全量覆盖
---
## 关键外部依赖(阻塞项,需运营方/医生提供)
- [ ] 🔴 设备可测指标清单 + 精度 + 认证(阻塞 T-3.3)— C-2
- [ ] 🔴 监管定位最终确认(阻塞 T-9.3)— C-1
- [ ] 🟠 医学阈值/孕周分段/红旗规则(阻塞 T-4.1/T-4.3
- [ ] 🟠 知识库权威来源(阻塞 T-7.1)
## 变更记录
| 日期 | 变更 | 说明 |
|------|------|------|
| 初版 | 创建 | 基于已确认的 0-req / 1-prd 生成 |
| 阶段5启动 | T-0.1/T-0.2 完成 | NestJS 后端脚手架就绪(build+test 通过);架构 ADR 见 4-arch-PCM.md |
| 阶段5 | T-2.3 完成 | patient 模块:孕周/分期、BMI、初始风险分层(可解释)、建档接口;21 项测试通过 |
| 阶段5 | T-3.2/3.4/4.1/4.2/4.4 完成 | observation 录入+质控、analysis 规则引擎+风险分层+可追溯预警;监测闭环打通,39 项测试通过 |
| 阶段5 | T-5.1/5.2 完成 | caseflow 个案状态机 + 照护计划;预警联动个案流转,56 项测试通过 |
| 阶段5 | T-6.1/6.2/4.3 完成 | notification 推送 + reminder 个性化提醒(高风险运动→休息)+ redflag 红旗急症旁路;67 项测试 |
| 阶段5 | T-7.1/7.2/7.3 完成 | knowledge 知识库+RAG问答(溯源/不超纲)+ ai 决策建议(高风险人工兜底);75 项测试 |
| 阶段5 | T-1.1/1.3/2.1/2.2 完成 | auth 注册登录(scrypt+令牌)+ rbac 权限矩阵 + audit 审计 + 知情同意;94 项测试 |
| 阶段5 | T-9.1 + 集成 | GDM 端到端闭环 + 红旗集成测试;96 项测试全绿,后端可启动、全路由就绪 |
| 阶段5 | 后端 MVP 完成 | 阶段0–4 + 红旗全部完成;客户端(T-8.x)为独立前端批次,待后续 |
| 阶段5 | T-8.1 完成 | 孕妇端 `patient-app`Vite+React+TS):登录/注册(知情同意)、AI-first 首页、聊天问答(溯源/不超纲)、数据录入+可解释预警、个性化提醒(高风险运动→休息)、档案与隐私;build+lint 通过,dev 经 /api 代理与后端全链路联调通过。生产可经 Taro 移植小程序 |
| 阶段5 | T-8.3 完成 | 医护端 `admin-web`Vite+React+TS 专业后台):登录(角色门控)、工作列表、个案工作台(档案/状态机流转/可解释预警/观测/AI建议人工兜底/照护计划/红旗检查);build+lint 通过,经 /api 代理与后端端到端联调个案处置闭环通过(预警→开案→AI建议需人工确认→合法/非法流转→照护计划→红旗升级高风险) |
| 阶段5 | T-8.4 完成 | 运营/管理端并入 `admin-web`(按角色门控):知识库管理(录入/检索/分类过滤/问答测试) + 审计日志(按操作者/动作过滤);后端新增 `GET /knowledge``GET /audit` 端点 + 6 测试(共 102 通过)build+lint 通过,经 /api 代理联调通过(运营录入→检索/RAG溯源;管理员查审计)。审计鉴权守卫待 T-1.2 接入 |
| 阶段5 | T-8.2 完成 | 家属端 `family-app`(Vite+React+TS,复用孕妇端暖色风):注册(知情同意)→绑定孕妇(关怀码)→只读状态卡+提醒预览、提醒页、聊天问答(溯源)、我的(绑定管理);只读边界与 RBAC 一致;build+lint 通过,经 /api 代理端到端联调通过(注册→getPatient只读→listReminders→ask)。正式授权链路列入 V2 |
| 阶段5 | 鉴权守卫(T-2.1/2.2 强化) | 后端新增全局 JwtAuthGuard(令牌校验+注入 req.user@Public 放行 auth/health) + CapabilitiesGuard(按 @RequireCaps 的 RBAC 能力授权,越权 403+审计 access:deny);各端点已门控;RBAC 矩阵精化支持孕妇自助。新增 12 测试(共 114 通过);端到端联调:无token→401、孕妇自助2xx/越权 /audit→403、家属只读200/写→403、管理员审计200、个案管理师工作台闭环2xx。字段级加密(T-1.2)待 DB 集成 |
| 阶段5 | T-0.3 容器化与编排 | 后端多阶段 Dockerfile + 三前端 nginx 镜像(SPA回退+/api反代) + docker-compose(backend+3前端,健康检查) + 顶层 .env.example + Makefile + deploy-staging.sh + README 部署章节。已验证 compose config、后端容器入口 node dist/main.js 实跑(health200/401)、前端 dist 齐备、脚本 bash -n。⚠️ 沙箱无镜像仓库出网(node:20-alpine 拉取失败)+守护不稳,live compose up 未在此环境执行 |
| 阶段5 | T-8.5 完成 | 多端一致:三前端共享 useAutoRefresh(focus+visibility+轮询),接入孕妇端数据/任务、家属端首页/提醒、医护端工作列表/工作台(静默刷新);单一后端真源。三端 build+lint 通过;数据一致性实跑(管理师发提醒→孕妇0→1;孕妇录入→管理师 alert 0→1+case 升 medium)。实时推送列 V2 |
| 阶段5 | T-9.2 完成 | 端到端场景演练 `scenarios.spec.ts` 覆盖 PRD §3.2 S1S7(服务层串联)S1分析/质控、S2 GDM全闭环、S3红旗、S4 RAG问答、S5纵向数据基础、S6运动→休息、S7工作台排序依据;后端 127 测试(124通过+3 todo)全绿。S5趋势预测/S6游戏化标 V2/V3 |
| 阶段5 | T-9.3 自检 | 产出 `5-compliance-PCM.md` 安全合规验收基线(映射 NFR-1/2/3/4/9、C-1~C-6,含证据与上线前必办)。已通过:同意/认证/RBAC/口令哈希/可解释/人工兜底/RAG不超纲/质控/审计(内存)。阻塞(上线前必办):TLS、存储加密、留存删除、审计持久化、记录级授权(多依赖 T-1.2 DB);监管定位 C-1 外部待确认。未达"逐项通过" |
| 阶段5 | 持久化地基(T-1.2) | 引入 PostgreSQL 可切换仓储(DATABASE_URL 门控;DatabaseModule 连接池+幂等建表),迁移 auth(users)/audit(audit_log);字段级加密工具 AES-256-GCM(field-crypto)+7 单测。后端 134 测试(131+3 todo)全绿(内存默认不回归)。本地 PG(pcm 库)实跑:注册/登录/越权审计入库,**重启后数据存活**、错误密码 401。审计持久化(NFR-9)与存储落库已解;字段加密应用于健康数据、TLS、记录级授权仍待 |
| 阶段5 | 字段级加密(T-1.2) | 迁移 patient/observation 到 PG,敏感字段(姓名/孕产史/日期/风险因素/测量值)经 FieldSealer(AES-256-GCM)加密入 enc 列,非敏感元数据留明文列。新增 field-sealer + 3 单测,后端 137 测试(134+3 todo)全绿。本地 PG 实跑:API 读回正确解密;**直查库内 patients.enc/observations.enc 为 v1: 密文、不含明文姓名/数值** → 字段级加密存储达成(迁移模块)。compose 加 db 服务+FIELD_ENCRYPTION_KEY |
| 阶段5 | 持久化全量完成(T-1.2) | 余下模块全部迁移 PG 可切换仓储:analysis(alerts)/caseflow(cases+care_plans)/reminder/knowledge;敏感内容(预警 value/规则/说明、个案流转历史、计划目标/干预、提醒文案)加密入 enc,知识为公开内容明文可检索。reminder 重构为仓储(list 异步)。后端 137 测试全绿(内存默认不回归)。本地 PG 全闭环实跑 + **重启后 case/care-plan/alert/reminder/knowledge 全部存活**(计划"控糖"、预警 medium/FG-1、知识问答 grounded 带溯源) |
| 阶段5 | UI 专业图标 | 三端(孕妇/家属/医护运营)全部 emoji 图标替换为 lucide-react 专业线性图标(导航/品牌/功能卡/提醒类型/状态/箭头/勾选/告警等),统一 size+strokeWidth+品牌色;三端 build+lint 通过,源码无残留 emoji |
| 阶段5 | UI 炫酷暖色(孕妇/家属端) | 借鉴 CapCut AI-first 布局(渐变 Hero + Quick Starts + 2x2 柔彩意图卡 + Profile 卡),配色转为暖色渐变(珊瑚→粉)与暖系柔彩卡;新增 design tokens(gradient-hero/primary/soft、radius-xl、6 组柔彩色)。孕妇端首页(渐变聊天 Hero/今日健康彩卡/意图 2x2)、家属端首页(渐变 Hero/状态卡/彩色提醒)、两端「我」页渐变 Profile 头卡。两端 build+lint 通过 |
| 阶段5 | UI 细节 | 孕妇/家属端字体改圆体(Yuanti SC)+更轻字重(细体活泼,兼顾可及性);三端 BrowserRouter 加 v7 future flags 消除告警;医护端去侧栏改顶部 Tab(2–3 分区更轻) |
| 阶段5 | 孕妇编号 + 列表分页 | 后端为每位孕妇生成人类可读编号 PCM-000001(顺序,PG patient_no 明文列+唯一索引,非敏感不入加密blob);新增按编号查找 getByNo + GET /patients/lookup?no=findAll 对解密失败行跳过告警(单行不致整表 500)。前端:admin 工作列表增编号列+按姓名/编号搜索+客户端分页(每页10),工作台头显示编号;孕妇端「我」关怀码改为编号(可复制),家属端凭编号绑定。后端 137 测试通过;本地 PG 实跑:建档得 PCM-000001/2/3、list/lookup 正常 |
| 阶段5 | 工作台全宽 + seed 示例数据 | 医护端内容区改为全窗口宽度(去 max-width 居中)。新增 `npm run seed`(backend/src/seed.ts,仅 PG):清空领域数据→经服务层写入真实示例(账号 caseManager/physician/operator/admin/mama01/family01 密码 pcm12345、知识库 5 条、12 名孕妇 PCM-000001…含不同风险,部分含预警/个案/计划/提醒)。本地 PG 实跑验证:12 孕妇编号/风险/孕周正确、张静含 medium 预警+个案 assessment+计划+提醒、知识问答带溯源 |
| 阶段5 | 工作台流程化重构(T-8.3 强化) | 重设计孕妇个案工作台,按 PRD §5.1 监测-分析-处置闭环与 §5.2 个案生命周期呈现:①全宽「个案流程」主干含阶段说明(当前阶段语义+建议下一步);②两列分区标题——左「监测与分析(数据→质控→分析→预警)」(预警+观测)、右「处置与计划(AI建议→人工确认→照护计划→提醒/随访)」(建议+计划+提醒+红旗);③新增「提醒与随访」面板(按类型图标,风险调整标注);④「个案动态时间线」——合并开案/流转/观测/预警/计划/提醒并按时间倒序,带类型筛选、孕周标注、语义配色,置于左列底部与右列红旗检查同排。admin-web 新增 Reminder 类型与 listReminders、reminderLabel/stageExplainer、RemindersPanel/CaseTimeline 组件、badge-info。build+lint 通过;本地 PG 实跑:张静(PCM-000001)时间线含全部 6 类事件 |
| 阶段5 | 工作台可理解性(处置指引) | 针对“看不懂这页怎么工作”反馈,新增顶部「处置指引」卡(CaseGuidePanel):用 4 步阅读地图(看阶段→看数据→做处置→查留痕)讲清页面结构与各板块关系,给出状态摘要(待处理预警/观测/计划/提醒/当前阶段)与“此刻建议”(随阶段+是否已有计划动态生成的下一步动作),并在有待处理预警时高亮优先提示。明确两条线(左数据进/右处置出)+流程主干(生命周期位置)+时间线(留痕)的心智模型。build+lint 通过 |
| 阶段5 | 执行流程设计(6-exec-PCM.md) | 依用户确认的 5 项决策(处置单独立实体/master-detail 驾驶舱/身心健康纳入MVP/通用达标参数/转诊会诊本期纳入)与“管理主线·一目了然·行动+反馈·孕期数据湖→增值礼物”原则,产出 `6-exec-PCM.md`:个案管理执行闭环(掌握→分析→处置→跟进→评价)、处置单领域模型(动作组+状态机+跟进达标判定+通用目标参数表+转诊/会诊+情绪打卡)、执行驾驶舱页面 IA/线框、后端新模块与 API 草案、数据湖与增值礼物、KPI、新增需求 REQ-D1~D6、MVP 分期与 T-D.* 任务大纲。待确认后回写 0-req/1-prd 并拆 2-task 实现 |
| 阶段5 | 指标体系文档(7-indicator-PCM.md) | 针对“指标体系在哪体现”的缺口,产出独立 `7-indicator-PCM.md` 指标字典:指标字典数据结构(可配置化, 去硬编码)、8 大分类(代谢/心血管/体格/营养微量/胎儿/中医/心理/其他)与全量明细(含状态图例 ✅实现/🟡规则待补/🟠待设备/🔵V2/🟢本期)、孕周分段阈值框架、红旗组合、质控门、LOINC/SNOMED 编码、与分析/数据湖/达标的关系、现状快照(实现8/有规则5/孕周分段未填充)、分期与依赖(C-2 设备清单+医生阈值)、新增需求 REQ-I1~I4。诚实盘点现状对齐代码 |
| 阶段5 | 需求链回写(0-req/1-prd) | 将 6-exec/7-indicator 的设计深化正式回写需求链:`0-req-PCM.md` 新增术语(处置单/处置动作/跟进项/指标字典/身心信号/数据湖)、功能需求 **REQ-D1~D6**(处置闭环与执行)与 **REQ-I1~I4**(指标体系)(EARS, 标注与既有 REQ-1/3/6/10/11/12 的深化/承接关系)、待确认事项 +3(达标参数/情绪工具/增值产出);`1-prd-PCM.md` 新增场景 S8~S10、功能清单 §4.2(映射 REQ-D/REQ-I + MoSCoW/版本)、执行流 §5.1(掌握→分析→处置→跟进→评价)、RBAC +7 行(处置单/转诊会诊/情绪)、版本规划 V1 纳入执行层、KPI 增处置闭环率/复测达标率。两文档头加增补说明 |
| 阶段5 | 详情两页架构(工作台/健康档案) | 确认孕妇详情拆两页、胶囊 Tab 切换、默认「工作台」:①「工作台」(6-exec 驱动)=行动视角,含态势/待处置队列/处置工作区/个案流程/照护计划/红旗;②「健康档案」(7-indicator §13 驱动)=数据视角,含指标概览/趋势/全部观测/动态时间线(从工作台迁入)/身心曲线/静态档案。同一数据两视角不重复。后端实体/接口不变(纯前端信息架构)。同步:6-exec §5 改两页+处置页规格(移出时间线)、7-indicator 新增 §13 健康档案 UI 规格、1-prd §4.2/§5.3 两页、3-ui-style §8.1 胶囊 Tab 规范 |
| 阶段5 | 终端形态(医护端=平板iPad) | 明确医护端「工作台」移动形态以**平板(iPad)** 为主、按 iPad 适配(非手机)0-req §3.1 终端表与 REQ-13.3/13.4 改为 PC+平板(iPad)、手机仅轻量查看;6-exec §5 响应式细化(PC/iPad横屏≥1024 master-detail、iPad竖屏<1024 堆叠/队列抽屉、触控≥44px无悬停依赖);3-ui-style §8.1 终端适配补充;1-prd §4.1 终端行更新 |
| 阶段5D | T-D 任务拆解 + T-D.1 完成 | 2-task 新增「阶段 5D:处置闭环与执行 MVP」11 个任务(T-D.1~D.11,带目标/REQ映射/验收/依赖)。**T-D.1 后端处置单完成**:新增 `src/modules/disposition/*`(领域模型+状态机+服务+内存/PG仓储+控制器+模块+单测)RBAC +disposition:create/read/confirm/execute(授管理师/医生,admin只读)schema +dispositions 表(敏感内容 FieldSealer 加密入 enc)app.module 注册。`nest build` 通过、**147 测试(144+3 todo)全绿**(+9 disposition)。本地 PG 实跑全闭环:medium→待确认、未确认执行→400(人工兜底 REQ-10.3)、确认→执行中、动作全落地→跟进中、闭环→met、list=1;孕妇越权创建→403;库内 enc 为 `v1:` 密文(明文标题不present) |
---
> 请检查本任务文档。确认通过后进入阶段 5(按任务执行开发),届时将逐项推进、测试并更新本文件勾选状态。
+117
View File
@@ -0,0 +1,117 @@
# 3-ui-style-PCM · 孕产个案管理平台 UI 设计风格指引
> 项目缩写:**PCM** 关联:`1-prd-PCM.md`PRD)、`2-task-PCM.md`T-8.1 孕妇端)
> 风格参考:CapCut 新版 AI-first 首页(https://ychenw.github.io/capcut-app-optimization/
> 定位:借鉴参考页的**布局与交互模式(AI 对话优先 + 意图驱动)**,但配色与语气换为**温暖、柔和、低焦虑**的孕产健康风格。
> 适用范围:**孕妇端 / 家属端(移动端)为主**;医护/运营端(PC)沿用专业后台规范,仅复用品牌色与组件。
---
## 1. 设计原则
1. **对话优先(AI-first**:孕妇端首页即"和孕期助手聊天",AI 是主入口,而非功能菜单堆叠。(对应 REQ-7 / REQ-10.1
2. **意图驱动**:用"你今天想做什么/问什么"引导,而非罗列工具。降低使用门槛。
3. **温暖低焦虑**:健康场景不制造恐慌,视觉柔和、语气有温度、预警分级克制。(对应 NFR-5)
4. **分层递进**:上层放 AI 与关怀,下层才是录入/档案/报告等"功能"。
5. **可及性优先**:大字号、高对比、单手可达、操作路径短(孕妇可能疲惫/视力变化)。
## 2. 借鉴参考页的交互模式(保留)
| 参考页元素 | PCM 孕妇端对应 |
|------------|----------------|
| "Chat to bring ideas alive" 聊天主入口 | "孕期助手"对话作为首页核心 |
| "What are you making today?" 意图卡片 | 孕期场景意图卡片(见 §5)|
| Quick Starts 最近项目缩略图 | "今日健康":今日待办/打卡/最近指标 |
| "For detailed edits" 专业工具区(下层)| "更多功能":录入、档案、报告(下层)|
| 底部 5 Tab 导航 | 底部 4 Tab(见 §6|
| 大圆角卡片、网格、卡片式 CTA | 沿用大圆角卡片式布局 |
## 3. 视觉语言(与参考页的差异:暖而非炫)
> 参考页是深色炫酷风;PCM 改为明亮温暖风。以下为**建议方向**,最终以视觉设计稿为准。
- **基调**:明亮、柔和、亲和。浅色背景为主,避免深色压抑。
- **主色(建议)**:柔和的暖色系——温柔粉/蜜桃、奶绿、暖米白;点缀低饱和的安心蓝/绿。
- **语义色**
- 正常/安心:柔和绿
- 注意/提醒:暖黄(非刺眼)
- 高风险/红旗:克制的红/橙,**仅用于真正紧急**,避免日常滥用造成焦虑
- **圆角**:大圆角(卡片、按钮、对话气泡),传达柔和。
- **字体**:无衬线、字号偏大、行距宽松,保证可读性。
- **图标/插画**:圆润、亲和的孕育主题插画,弱化"医疗冰冷感"。
- **留白**:充足留白,信息密度低,减少认知负担。
## 4. 语气(Voice & Tone
- 像一位**温柔、专业、可靠的孕期陪伴者**,不是冷冰冰的系统。
- 鼓励、肯定、非评判(如"今天也记录啦,真棒~"而非"你已 3 天未测量")。
- 预警措辞分级、克制、给行动指引,不渲染恐慌。
- 中文为主,口语化、亲切。
## 5. 孕妇端首页结构(核心页)
```
┌─────────────────────────────┐
│ 问候 + 孕周 + 今日一句关怀 │ ← 顶部:温暖问候,显示孕XX周
├─────────────────────────────┤
│ [ 和孕期助手聊聊… ] 💬 │ ← 聊天主入口(AI-first)
├─────────────────────────────┤
│ 今日健康 │ ← Quick Starts 对应
│ [今日打卡] [血糖趋势] [产检提醒] │
├─────────────────────────────┤
│ 你可能想问 / 想做 │ ← 意图卡片(意图驱动)
│ ┌──────┐┌──────┐┌──────┐ │
│ │我能吃X吗││记录血糖 ││今天注意啥│ │
│ └──────┘└──────┘└──────┘ │
├─────────────────────────────┤
│ 更多功能 │ ← 下层:录入/档案/报告
│ [手动录入][健康档案][我的报告][调养] │
└─────────────────────────────┘
首页(助手) 数据 任务/打卡 我 ← 底部 Tab
```
### 意图卡片示例(替代参考页的"Create a vlog"等)
- "我能吃这个吗?" → 触发知识库问答(REQ-7)
- "记录今天的血糖/血压" → 手动录入(REQ-1
- "今天该注意什么?" → 个性化关怀与计划提醒
- "预约/查看产检" → 计划与提醒
- "孕期不适怎么办" → 知识问答 + 必要时升级
## 6. 底部导航(孕妇端)
| Tab | 内容 |
|-----|------|
| 首页(助手)| 聊天主入口 + 今日健康 + 意图卡片 |
| 数据 | 指标录入、趋势、健康档案 |
| 任务/打卡 | 提醒、打卡、(V2 游戏化)|
| 我 | 个人信息、家属授权、设置、隐私 |
## 7. 关键组件规范(要点)
- **对话气泡**:AI 答复必须**附知识来源**(REQ-7.2 溯源),样式轻量可点开。
- **预警卡片**:分级配色;高风险/红旗用醒目但不惊悚的样式,附"立即就医/联系管理师"明确按钮(REQ-5)。
- **提醒卡片**:温和提示,可一键完成打卡;高风险孕妇运动提醒自动替换为休息(REQ-9.2)。
- **录入控件**:大按钮、少步骤、支持快速重复录入。
## 8. 与医护/运营端(PC)的关系
- PC 端为**专业工作台**(信息密度高、表格/看板/详情),不套用孕妇端的暖萌风。
- 跨端**共享品牌色、字体、核心组件库**,保持品牌一致。
- 设计交付建议:建立统一 Design Token(颜色/间距/圆角/字号)+ 组件库,双端复用。
### 8.1 医护端孕妇详情:两页(胶囊 Tab)
- 详情页用**胶囊式分段控件(segmented pill)**切换两页,默认「工作台」:
- **工作台**:处置执行台(态势→待处置队列→处置工作区→个案流程/计划/红旗)——行动视角。
- **健康档案**:数据汇集(指标概览/趋势/全部观测/动态时间线/身心/静态档案)——数据视角。
- 胶囊 Tab 置于孕妇头内,切换不离开当前孕妇;保持专业中性风(非暖萌),高信息密度、可达性字号/对比度达标。
- **终端适配**:医护端移动形态以**平板(iPad)** 为主,按 iPad 适配——iPad 横屏(≥1024px)用左右 master-detailiPad 竖屏(<1024px)上下堆叠/队列抽屉化;触控友好(点按区≥44px、无悬停依赖);手机仅轻量查看。
- 规格详见 `6-exec-PCM.md` §5(工作台)与 `7-indicator-PCM.md` §13(健康档案)。
## 9. 待确认 / 下一步
- [ ] 品牌主色与 Logo(待运营方品牌输入)
- [ ] 是否区分 App / 小程序(影响交互与组件选型)
- [ ] 高保真视觉稿(建议先出孕妇端首页 + 对话页 + 预警页)
- [ ] 如有参考页截图,可拖入对话以做更精准的视觉还原
---
> 本文为风格指引,供阶段 5 实现孕妇端(T-8.1)时参考;视觉以后续设计稿为准。
+55
View File
@@ -0,0 +1,55 @@
# 4-arch-PCM · 架构决策记录(ADR
> 项目缩写:**PCM** 关联:`2-task-PCM.md` T-0.2
> 状态:V1 基线架构(可随实现迭代)
## 1. 技术栈决策
| 层 | 选型 | 理由 |
|----|------|------|
| 后端 API | **NestJS (Node.js/TypeScript)** | 模块化、TS 类型安全、生态完善、适合分层与 DDD |
| 数据库 | **PostgreSQL** | 关系 + JSONB + 时序友好;成熟稳定 |
| 向量检索 | 向量库(pgvector / 独立向量服务)| 知识库 RAG 检索 |
| 孕妇/家属端 | **微信小程序** | 国内孕妇触达成本最低、无需安装 |
| 医护/运营端 | **React Web 工作台** | 信息密度高的 PC 后台 |
| AI 能力 | 大模型 API + **RAG(检索增强 + 强制溯源)** | 防幻觉、可溯源(REQ-7.2/7.3|
| 消息推送 | 小程序订阅消息 / 短信 / App push | 提醒与预警触达(T-6.1|
## 2. 仓库结构(Monorepo
```
pcm-platform/
├── backend/ # NestJS API(核心后端)
│ ├── src/
│ │ ├── modules/ # 业务模块(按领域划分)
│ │ ├── common/ # 通用(鉴权、日志、审计、异常)
│ │ └── main.ts
│ └── package.json
├── admin-web/ # React 医护/运营端(后续阶段)
├── miniapp/ # 微信小程序 孕妇/家属端(后续阶段)
└── docs/ # 引用根目录 req/prd/task/ui-style
```
## 3. 后端模块划分(对应需求域)
| 模块 | 职责 | 需求映射 |
|------|------|----------|
| auth | 账号、登录、会话 | REQ-11、T-2.1 |
| rbac | 角色权限 | REQ-11.1、T-2.2 |
| patient | 孕妇档案、建档、孕周、纵向时间轴 | REQ-2、T-2.3/T-3.1 |
| observation | 观测值录入/采集/质控 | REQ-1、T-3.x |
| analysis | 规则引擎、风险分层、预警 | REQ-3、T-4.x |
| redflag | 红旗急症旁路 | REQ-5、T-4.3 |
| caseflow | 个案管理流程、照护计划 | REQ-6、T-5.x |
| knowledge | 知识库、RAG 问答 | REQ-7、T-7.x |
| reminder | 提醒、推送 | REQ-9、T-6.x |
| ai | 交互中枢、决策建议、人工兜底 | REQ-10、T-7.3 |
| audit | 操作审计、合规留痕 | NFR-1/9、T-1.3 |
## 4. 关键架构原则
- **可解释/可追溯**:风险、预测、AI 建议保留溯源链与留痕(NFR-3)。
- **人工兜底**:高风险/急症需人工确认,系统不自动执行临床决策(REQ-10.3)。
- **数据合规**:敏感数据加密、最小化、分级访问、审计(NFR-1)。
- **可配置扩展**:指标、规则、知识、设备类型可配置,不改核心代码(NFR-7)。
- **手动录入兜底**:不依赖设备对接即可上线(应对 C-2)。
+100
View File
@@ -0,0 +1,100 @@
# 5-compliance-PCM · 安全与合规验收清单(T-9.3)
> 项目缩写:**PCM** 上游:`0-req-PCM.md`NFR/约束)、`1-prd-PCM.md`(§8 合规)、`4-arch-PCM.md`
> 用途:上线前安全与合规基线核查。逐项标注现状、证据(代码位置)与上线前必办事项。
> 状态图例:✅ 已实现 ⚠️ 部分(V1 占位/待补强) ❌ 待办 🔴 外部阻塞(待运营方/法务/医生)
> ⚠️ 说明:本清单为**工程自检基线**,不替代法务/DPO 的正式合规评审与(如适用)监管注册评估。
---
## 1. 监管定位声明(NFR-2 / C-1
- 产品定位:**健康管理 / 临床决策支持**,**非诊断、非急救替代、非处方**。
- 工程体现:
- AI 决策建议对中/高风险 `requiresHumanConfirmation=true`,系统不自动执行临床决策(人工兜底)。
- 知识问答强制 RAG + 溯源,无依据明确告知并建议就医,不超纲生成。
- 红旗急症仅给"立即就医"提示并通知医护,不下诊断结论。
- 孕妇端/家属端知情同意文案明示"提供健康陪伴与信息参考,不替代医生的诊断与治疗"。
- 🔴 **上线前必办**:监管定位与合规边界的**最终确认**(C-1,待运营方/法务);若功能演进触及 SaMD 边界须重新评估 NMPA 注册。
---
## 2. 合规核对清单
### NFR-1 安全合规(PIPL / 数据安全法)—— 拆分逐项
| 项 | 要求 | 现状 | 证据 / 说明 |
|----|------|------|-------------|
| 1.1 知情同意 | 使用前签署,留存可审计 | ✅ | `auth.service.ts`:孕妇/家属未签 `consent` 拒绝注册;`consentSigned` 留存;前端登录页含同意勾选 + 隐私政策页 |
| 1.2 身份认证 | 会话安全、令牌校验 | ✅ | 全局 `JwtAuthGuard` 校验签名令牌 + 过期;`token.ts` HMAC + 过期;8h TTL |
| 1.3 分级权限 | 按角色访问控制 | ⚠️ | 能力级:`CapabilitiesGuard` + `rbac.ts` 矩阵,越权 403。**记录级**(仅本人/负责个案/绑定孕妇)待补(无用户↔孕妇关联表) |
| 1.4 口令保护 | 不明文存储 | ✅ | `password.ts`scrypt + 16B 随机盐 + `timingSafeEqual`;最短 8 位。生产建议 argon2/bcrypt |
| 1.5 传输加密 | TLS | ❌ | 应用层未含 TLS;须在入口(nginx/ingress/网关)终止 TLS。**上线前必办** |
| 1.6 存储加密 | 敏感字段加密、最小化 | ✅ | PostgreSQL 持久化(auth/audit/patient/observation`DATABASE_URL` 门控);**敏感 PII/PHI 字段级加密已落地**——patient(姓名/孕产史/日期/风险因素)、observation(测量值) 经 AES-256-GCM 入 `enc` 列(`field-sealer.ts`/`field-crypto.ts`),实跑验证库内为密文、不含明文。非敏感元数据留明文列以供检索。生产经 KMS 注入 `FIELD_ENCRYPTION_KEY` 并轮换 |
| 1.7 数据最小化 | 仅采所需 | ⚠️ | 建档/录入 DTO 仅收必要字段;代码示例/日志未回显敏感值。缺正式数据分级清单 |
| 1.8 留存与删除 | 留存策略 + 删除权 | ❌ | 未实现数据留存期与删除/导出(PIPL 主体权利)。**上线前必办** |
| 1.9 审计日志 | 关键操作留痕 | ✅ | `audit.service.ts` 记录注册/登录/越权(access:deny)`audit.controller.ts``audit:read`(admin)可查。**持久化可用**:PG `audit_log``DATABASE_URL` 门控),已实跑入库+重启存活验证。生产再加防篡改/WORM 与归档 |
### NFR-2 监管定位 / C-1
| 项 | 现状 | 证据 |
|----|------|------|
| 非诊断定位的工程约束 | ✅ | 人工兜底 + RAG 不超纲 + 红旗仅就医提示(见 §1) |
| 监管定位最终确认 | 🔴 外部 | C-1,待运营方/法务确认 |
### NFR-3 可解释性
| 项 | 现状 | 证据 |
|----|------|------|
| 风险/预警可追溯 | ✅ | `Alert``ruleIds`+`messages`+`observationId``scenarios.spec.ts` S1 断言可追溯 |
| AI 建议可解释 | ✅ | `Recommendation.rationale` 溯源到预警说明 |
| 问答溯源 | ✅ | `QaAnswer.citations`(标题/来源/权威级别) |
### NFR-4 临床安全 / C-4
| 项 | 现状 | 证据 |
|----|------|------|
| 红旗人工兜底 | ✅ | `redflag.service.ts` 即时就医 + 通知管理师/医生 + 升级高风险;S3 测试 |
| 高风险 AI 必须人工确认 | ✅ | `recommendation.ts` 中/高风险 `requiresHumanConfirmation=true`;工作台"确认采纳"门控 |
| 误报/漏报责任边界与处置 SLA | ❌ | 责任边界与 SLA 为运营/医疗制度,未在系统固化。**上线前必办(制度+配置)** |
### NFR-9 可观测/审计
| 项 | 现状 | 证据 |
|----|------|------|
| 全链路审计 | ⚠️ | 已覆盖鉴权/越权;处置/配置类操作的审计点待逐步补全;存储须持久化 |
### 约束核查
| 约束 | 现状 | 证据 / 说明 |
|------|------|-------------|
| C-1 监管定位(🔴最高) | 🔴 外部 | 待最终确认 |
| C-2 设备指标清单(🔴最高) | 🔴 外部 | 待运营方;V1 以手动录入兜底,不阻塞上线核心闭环 |
| C-3 数据合规隐私(🔴最高) | ⚠️ | 同意/审计/口令已实现;传输/存储加密、留存删除待办(见 1.5/1.6/1.8 |
| C-4 红旗+人工兜底(🟠高) | ✅ | 见 NFR-4 |
| C-5 低质量数据不驱动高风险(🟠高) | ✅ | `quality-control.ts` 拒绝不可信值,rejected 不进入分析;S1 测试断言 |
| C-6 RAG 溯源不超纲 + 高风险人工确认(🟡中) | ✅ | 见 NFR-3 / NFR-4 |
---
## 3. 上线前必办(阻塞项)
1.**传输加密 TLS**(1.5):入口层 TLS 终止 + HSTS。
2.**存储加密与持久化**(1.6 / T-1.2):全部领域仓储已迁移 PostgreSQLauth/audit/patient/observation/analysis/caseflow/reminder/knowledge+ 敏感字段 AES-256-GCM 加密入库,本地 PG 全闭环 + 重启存活验证。**剩**:生产 KMS 密钥管理与轮换。
3.**数据留存与删除/导出**(1.8):留存期策略 + 主体删除/导出(PIPL 权利)。
4. ⚠️ **审计持久化**1.9):PG `audit_log` 持久化已落地;**剩** 生产防篡改/WORM 存储与归档策略。
5. ⚠️ **记录级授权**(1.3):用户↔孕妇关联,强制"仅本人/负责个案/绑定孕妇"。
6.**误报/漏报责任边界与处置 SLA**(NFR-4):制度化并在系统配置/留痕。
7. 🔴 **监管定位最终确认**(C-1)+ 医学阈值/红旗规则由医生确认(外部)。
8. 🔁 **密钥与配置**`AUTH_SECRET` 强随机 + 轮换;密钥管理(KMS/Secrets);勿用默认值。
---
## 4. 验收结论
- V1 工程层面已落地:知情同意、认证、能力级 RBAC、口令哈希、可解释/可追溯、人工兜底、RAG 不超纲、数据质控、审计(内存)。
- **不满足"无条件上线"**:传输/存储加密、数据留存删除、审计持久化、记录级授权、监管定位确认为阻塞项(§3)。
- 建议:完成 §3 阻塞项(多数依赖 PostgreSQL 持久化与部署层 TLS)后,由法务/DPO 正式评审再灰度上线(T-9.4)。
---
## 变更记录
| 日期 | 变更 | 说明 |
|------|------|------|
| T-9.3 | 创建 | 基于代码现状的安全与合规验收基线;映射 NFR-1/2/3/4/9 与 C-1~C-6 |
+381
View File
@@ -0,0 +1,381 @@
# PCM 个案管理执行流程与处置闭环设计
> 文档定位:本文件是孕妇个案工作台(医护端)的**执行流程设计与开发指导**,把 1-prd §5.1(监测-分析-处置闭环)、§5.2(个案生命周期)落到"可操作、可闭环、可跟踪"的工作台执行流。
>
> 关系:上承 `0-req-PCM.md`(需求)、`1-prd-PCM.md`PRD)、`0-1-基础构想.md`(个案管理师体制 / 三大本体 / 孕妇纵向时间轴);与 `3-ui-style-PCM.md`UI 规范)、`4-arch-PCM.md`(架构)、`5-compliance-PCM.md`(合规)并列。本文新增的需求点回写 0-req/1-prd,任务拆解回写 `2-task-PCM.md`。
>
> 语言:中文。系统定位为**健康管理 / 临床决策支持,非诊断**(见 5-compliance);所有医学参数为通用默认值,须医生最终确认。
---
## 1. 设计原则
1. **有管理主线**:全页围绕一条主线运转——`掌握 → 分析 → 处置 → 跟进 → 评价`(个案管理闭环)。任何信息与操作都能在主线上找到位置,不再是平铺的面板墙。
2. **重要信息一目了然**:进入页面 5 秒内回答三问——这位孕妇现在什么状况?最该管哪件事?我该做什么?由顶部"态势卡"+左侧"待处置队列优先级"承载。
3. **清晰准确地采取行动,并获取行动反馈**:处置是一等公民——可打包成"一个或一组动作"、可指派、可执行、可跟踪结果(复测达标与否),形成"行动→反馈→再行动"的闭环。
4. **沉淀全方位孕期数据湖,加工为送给孕妇的增值礼物**:每一次观测、预警、处置、跟进、情绪打卡,都按"孕妇 + 孕周时间轴"汇流为纵向数据湖(孕妇本体);孕期结束后加工成"孕期全纪录"作为增值礼物回馈孕妇(见 §8)。
5. **身心并重**:不仅监测血糖血压等临床指标,纳入情绪/焦虑维度与关怀类处置,呼应"健康监管 + 完美孕期体验"。
6. **AI 赋能、人机协同、医生兜底**:AI 出研判与建议处置单,管理师确认与个性化调整,中/高风险与急症由医生兜底;全程可解释、可追溯、留痕(合规)。
---
## 2. 已确认的关键决策(设计基线)
| # | 决策点 | 结论 |
|---|--------|------|
| D1 | 处置单形态 | **独立新实体** `Disposition`(语义清晰,不混入照护计划) |
| D2 | 页面交互 | **master-detail 驾驶舱**:左"待处置队列" + 右"处置工作区" |
| D3 | 身心健康 | **纳入 MVP**:孕妇端新增情绪打卡,医护端纳入身心信号与关怀类处置 |
| D4 | 达标判定 | 先用**通用默认目标参数 + 时间窗**(见 §4.4 表),医生可覆盖确认 |
| D5 | 转诊/会诊 | **本期纳入**,含医生端协同 |
---
## 3. 核心执行闭环模型
```
①掌握(态势) → ②分析(研判) → ③处置(动作/动作组) → ④跟进(复测·结果) → ⑤评价(达标?)
↑ │
└───────────────────── 未达标 / 新预警 / 风险变化 → 再循环 ←──────────────────┘
⚡ 红旗急症随时旁路:直接升级转诊 + 通知医生,绕过常规循环
```
每一步的输入—动作—产出,以及个案管理师角色(评估者/规划者/协调者/监督者/教育者/倡导者)与 AI/人/医生的分工:
| 步骤 | 管理师角色 | 输入 | 动作 | 产出 | AI / 人 / 医生 |
|------|-----------|------|------|------|---------------|
| ① 掌握 | 监督者 | 观测、预警、趋势、情绪、待办 | 浏览态势卡、选取最高优先项 | 锁定要处理的"事" | AI 汇总研判;人选择 |
| ② 分析 | 评估者 | 单条预警/风险/情绪信号 | 看数据+趋势+规则+知识溯源+AI 研判 | 形成处置意图 | AI 研判;人复核 |
| ③ 处置 | 规划者/协调者 | AI 建议处置单 | 编辑动作组→(中/高风险)确认→执行 | 已执行的处置单 | AI 建议;人确认;医生兜底 |
| ④ 跟进 | 监督者 | 跟进项到期、复测数据回流 | 复测比对目标 | 达标/未达标结论 | AI 自动比对;人判断 |
| ⑤ 评价 | 评估者 | 处置有效性、阶段达成 | 推进/回退个案阶段 | 个案阶段流转、KPI | 人决策 |
红旗急症旁路贯穿全程:任何时刻命中危急组合(子痫前期征象、胎动消失、阴道大量出血、严重血糖异常等)→ 立即"就医/转急诊"提示 + 通知医生 + 个案升级高风险,不等常规循环。
---
## 4. 「处置单」领域模型(方案心脏)
### 4.1 实体关系
```mermaid
graph TD
P[孕妇 Patient] --> C[个案 Case]
AL[预警 Alert] -->|触发| D[处置单 Disposition]
EM[情绪信号 EmotionSignal] -->|触发| D
RF[红旗 RedFlag] -->|触发/升级| D
C --> D
D --> A1[处置动作 Action #1]
D --> A2[处置动作 Action #2..N]
D --> F[跟进项 FollowUp]
A1 -.落地.-> RM[提醒 Reminder]
A2 -.落地.-> CP[照护计划 CarePlan]
D -.可派生.-> REF[转诊/会诊 Referral]
F -->|复测回流| OB[观测值 Observation]
F --> OUT[结果 达标/未达标]
```
要点:处置单**向后关联触发来源**(哪条预警/风险/情绪/红旗,可追溯),**向前打包一组动作**并生成**跟进项**,跟进项绑定复测指标与达标目标,复测数据回流后判定结果,闭环。
### 4.2 处置单状态机(Disposition lifecycle
```
draft(草拟,AI 生成或手工新建)
→ pending_confirmation(待确认:中/高风险必经;低风险可跳过)
→ in_progress(执行中:动作陆续落地)
→ following_up(跟进中:动作已执行,等复测/到期评估)
→ closed(已闭环)
├─ outcome = met 达标(处置有效)
├─ outcome = not_met 未达标 → 一键发起"再处置"(新处置单,关联本单)
└─ outcome = escalated 升级(转诊/会诊/红旗)
```
字段(草案):
```
Disposition {
id, caseId, patientId
sourceType: 'alert' | 'risk' | 'redflag' | 'emotion' | 'manual'
sourceId? // 触发来源实体 ID(可追溯)
title // 如 "收缩压 150 处置"
riskLevelAtCreation // low|medium|high(决定是否需确认)
requiresConfirmation // medium/high = true(医生/管理师兜底)
status // 见状态机
closureOutcome? // met|not_met|escalated
createdBy, confirmedBy?
createdAt, updatedAt, closedAt?
actions: DispositionAction[]
followUps: FollowUp[]
}
```
### 4.3 处置动作目录(DispositionAction
一张处置单含 1~N 个动作。动作类型对齐基础构想"干预 Intervention"分类(clinical/lifestyle/habit+ 协调/教育/关怀:
| 动作类型 kind | 说明 | 关键参数 | 落地能力 | 需医生确认 |
|---|---|---|---|---|
| `reminder` | 下发健康提醒 | type(运动/休息/喝水/服药/产检/打卡)、文案 | 复用 reminder.dispatch ✅ | 否 |
| `care_plan` | 制定/调整照护计划 | goals、interventions、followUpFrequency | 复用 carePlan ✅ | 中/高风险 |
| `monitor_freq` | 调整监测频率/指标 | 指标、频率 | 新增(计划元数据) | 否 |
| `recheck` | 安排复测/产检 | 指标、时间窗 | 新增 FollowUp ✅ | 否 |
| `education` | 推送健康教育 | knowledgeId(带溯源) | 复用 knowledge ✅ | 否 |
| `emotional_care` | 身心关怀/情绪疏导 | 关怀话术、随访方式 | 新增 | 否 |
| `referral` | 转诊 | 目标科室/机构、紧急度、理由 | 新增 Referral(§4.5 | 是 |
| `consult` | 医生会诊 | 目标医生、问题 | 新增 Referral(§4.5 | 是 |
| `medication` | 用药提醒(非开方) | 药品、频次(仅提醒,不诊疗) | 复用 reminder | 是 |
```
DispositionAction {
id, dispositionId
kind // 见上表
params // 依 kind 而定(结构化)
status: 'planned'|'executed'|'skipped'|'failed'
assigneeId?, dueAt?
executedAt?, resultNote?
linkedEntityId? // 落地后生成的 reminderId/carePlanId/referralId
}
```
> 安全约束:`medication` 仅为"提醒",不含开方/诊断(合规:非诊断定位)。涉及临床决策的动作(care_plan 中/高风险、referral、consult、medication)须人工/医生确认后方可执行(REQ-10.3 人工兜底)。
### 4.4 跟进与达标判定(闭环关键)
```
FollowUp {
id, dispositionId, patientId
indicator // 复测指标,如 systolic_bp / fasting_glucose
targetOperator, targetValue // 达标判定,如 '<' 140
windowDays // 复测时间窗
dueAt // 到期时间
status: 'pending'|'due'|'evaluated'
outcome?: 'met'|'not_met'
evaluatedObservationId?, evaluatedAt?
}
```
判定流程:跟进项到期 → 提醒管理师 → 孕妇复测数据回流 → 系统按 `indicator + targetOperator + targetValue` 自动比对 → `met`(闭环、记为处置有效)/ `not_met`(提示再处置或升级)。
**通用默认目标参数与时间窗(D4:先用通用值,医生可覆盖确认;非诊断标准)**
| 指标 | 达标目标(默认) | 复测时间窗 | 急症旁路阈值(红旗) |
|---|---|---|---|
| 空腹血糖 fasting_glucose | < 5.1 mmol/L | 37 天 | 极高/极低需即时处置 |
| 餐后 2h 血糖 ogtt_2h/postprandial | < 6.7 mmol/L | 37 天 | — |
| 收缩压 systolic_bp | < 140 mmHg | 13 天 | ≥ 160 当日/红旗 |
| 舒张压 diastolic_bp | < 90 mmHg | 13 天 | ≥ 110 当日/红旗 |
| 体重增长 weight | 按孕前 BMI 周增区间(如 BMI 正常 0.350.5 kg/周) | 1–2 周 | 短期异常增长→子痫前期信号 |
| 胎动 fetal_movement | 维持正常胎动模式 | 当日复评 | 胎动明显减少/消失→红旗 |
| 情绪自评 mood | 自评分回升至阈值以上 | 3–7 天 | 持续低落/危机表述→人工介入 |
> 以上为依据通行指南整理的**通用默认值**,仅用于健康管理目标设定与达标提示,**不构成诊断**,须由运营方医生最终确认并可按个体覆盖(高龄、既往 GDM/PIH 等收紧)。
### 4.5 转诊 / 会诊(D5,含医生端协同)
```
Referral {
id, caseId, patientId
type: 'referral'(转诊) | 'consult'(会诊)
toDept?/toOrg?/toPhysicianId?
reason, urgency: 'routine'|'urgent'|'emergency'
status: 'requested'|'accepted'|'completed'|'declined'
createdBy, createdAt
responses: { byPhysicianId, at, note, decision }[]
}
```
协同:管理师在处置单内发起 `referral`/`consult` → 医生端工作台收到待办 → 医生接受/回复/完成 → 状态与回复回流处置单与时间线。紧急转诊与红旗联动。
### 4.6 情绪打卡与身心信号(D3,孕妇端 + 医护端)
- 孕妇端新增**情绪打卡**:轻量自评(如 0–10 心情/焦虑自评 + 可选一句话),有温度、低压力、非诊断(避免量表化造成焦虑)。
- 后端派生**身心信号** EmotionSignal:如"连续 N 天低分"或"危机表述关键词"→ 生成一条待处置项(sourceType=`emotion`)。
- 医护端:态势卡显示"情绪/焦虑维度"灯;可发起 `emotional_care` 处置动作(关怀话术、电话随访、正念/放松推荐)。
- 危机信号(自伤/危机表述)→ 旁路人工即时介入,参照内容安全与转介流程。
### 4.7 风险门控与安全
- **门控**:低风险处置可直接执行;中/高风险处置单 = `requiresConfirmation`,AI 建议**不自动执行**,须管理师/医生确认(REQ-10.3)。
- **红旗旁路**:命中危急规则即时升级(转急诊提示 + 通知医生 + 个案升高风险),不等常规流程(REQ-4.3 / 基础构想附录五)。
- **可解释与留痕**:每条处置可追溯"预警→规则→知识依据→执行人→结果",写入审计与时间线(NFR-9 合规)。
---
## 5. 页面重构:详情两页 + 处置页「工作台」
> 孕妇详情拆为**两页**,用**胶囊式 Tab** 切换,共享孕妇头(姓名/编号/孕周/初始 vs 当前风险):
> - **「工作台」(默认)** — 本页,处置执行台(掌握→分析→处置→跟进),由本文档驱动。
> - **「健康档案」** — 数据汇集页(指标概览/趋势/全部观测/时间线/身心),由 `7-indicator-PCM.md` §13 驱动。
>
> 分工原则:**工作台=行动视角**(要处理的事);**健康档案=数据视角**(完整数据全貌)。同一条预警在工作台是"待办的事",在健康档案是"观测/趋势",视角不同、不重复出现。
> 归属:个案流程 / 照护计划 / 红旗急症 → **工作台**;动态时间线 / 全部观测 / 趋势图 → **健康档案**。
### 5.1 「工作台」信息架构与线框(master-detail,默认页)
```
┌─────────────────────────────────────────────────────────────────────────┐
│ ← 返回 王芳 PCM-000003 ( 工作台 ▮ 健康档案 ) ← 胶囊Tab·默认工作台 │
│ ┌─ 态势卡(掌握最新情况,管理主线起点) ───────────────────────────────┐ │
│ │ 当前风险 中 ↓(趋势) | 临床灯 ● 情绪灯 ● | 阶段 风险分层 │ │
│ │ 待办:预警 1 · 待执行处置 0 · 待跟进复测 0 · 漏测 0 │ │
│ │ 首要关注:收缩压 150(中风险预警,建议立即处置) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ ┌── 待处置队列(左·master) ──┐ ┌── 处置工作区(右·detail) ───────────┐ │
│ │ ▸ ⚠ 收缩压150 中风险 待处置 │ │ 【分析】观测 150mmHg·孕31周 │ │
│ │ ▸ ⏰ 血糖复测 到期 │ │ 趋势:↑(近3次)| 规则 SBP-1 │ │
│ │ ▸ 〰 情绪低落 3天 │ │ 知识依据:妊高征指南(溯源) │ │
│ │ ▸ ✓ 漏测打卡 │ │ AI 研判:妊娠期高血压可能 │ │
│ │ (按优先级排序) │ │ 【处置单】AI 建议(可编辑动作组): │ │
│ │ │ │ ☑ 下发休息提醒 │ │
│ │ │ │ ☑ 调整照护计划(监测↑/低盐饮食) │ │
│ │ │ │ ☑ 安排 2 天后复测血压(目标<140) │ │
│ │ │ │ ☐ 发起医生会诊 │ │
│ │ │ │ [中风险:需确认] → 确认并执行 │ │
│ │ │ │ 【跟进】2天后复测血压 → 达标判定 │ │
│ └────────────────────────────┘ └──────────────────────────────────────┘ │
│ ┌─ 收口/参考区(个案进程) ───────────────────────────────────────────┐ │
│ │ 个案流程阶段推进 | 现行照护计划 | 红旗急症检查 │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ (动态时间线 / 全部观测 / 趋势 → 见「健康档案」页) │
└─────────────────────────────────────────────────────────────────────────┘
```
### 5.2 各区职责
| 区 | 职责 | 对应主线步骤 | 复用现有组件 |
|---|---|---|---|
| 态势卡 | 风险+趋势、身心双灯、待办计数、首要关注、阶段 | ① 掌握 | 升级现 CaseGuidePanel + 档案头 |
| 待处置队列(左) | 按优先级聚合所有"需处置项"(预警/到期跟进/情绪/漏测),选中驱动右区 | ① → ② | 新增(聚合 AlertsPanel |
| 处置工作区(右) | 分析(数据/趋势/溯源/AI 研判)→ 处置单(动作组编辑+确认+执行)→ 跟进状态 | ②③④ | 新增(融合 RecommendationPanel |
| 收口/参考区 | 个案阶段推进、现行照护计划、红旗急症 | ⑤ | 复用 CaseFlowPanel / CarePlanPanel / RedflagPanel |
> 注:动态时间线、全部观测、趋势图移至「健康档案」页(数据视角,见 `7-indicator-PCM.md` §13);「工作台」只保留行动所需信息,保持专注。胶囊 Tab(工作台 ▮ 健康档案)置于孕妇头,默认「工作台」。
### 5.3 交互流(一次典型处置)
1. 进入页面 → 态势卡显示"首要关注:收缩压 150" → 左队列高亮该预警。
2. 点击预警 → 右区展示分析(趋势↑、规则 SBP-1、知识溯源、AI 研判)。
3. AI 生成"建议处置单"(休息提醒 + 调整计划 + 2 天后复测 + 可选会诊)。
4. 管理师勾选/编辑动作 → 中风险需"确认并执行" → 各动作批量落地(发提醒、改计划、建跟进项)。
5. 处置单转 `following_up`2 天后跟进项到期提醒。
6. 孕妇复测血压回流 → 自动比对目标<140 → 达标则闭环(met),未达标则一键"再处置"或升级会诊。
7. 全过程写入时间线与审计;推进个案阶段(监测→评价)。
> 响应式(医护端移动形态以 **平板 iPad** 为主,按 iPad 适配):
> - **PC + iPad 横屏(≥1024px**:左右 master-detail 并排(队列 + 工作区)。
> - **iPad 竖屏 / 窄屏(<1024px,约 768834px)**:左右改为上下堆叠,待处置队列折叠为可展开列表或抽屉。
> - **手机**:仅轻量查看/通知,不承载完整处置流。
> - **触控适配**:更大点按区(≥44px)、不依赖悬停、关键操作可点按;保持医护端专业中性风、可达性字号/对比度达标(3-ui-style §8.1)。
---
## 6. 后端改动与 API 草案
### 6.1 复用(不改或小改)
- `alerts`open/acknowledged/resolved)、`reminder.dispatch``carePlan``redflag``knowledge``case` 状态机、`audit`
### 6.2 新增模块/实体
- `disposition`(处置单 + 动作)、`followup`(跟进项)、`referral`(转诊/会诊)、`emotion`(情绪打卡 + 信号派生)。
- 趋势:基于既有 `observations` 计算"近 N 次趋势"(规则级,先不上预测模型,符合冷启动)。
### 6.3 端点草案(均含 RBAC 能力门控 + 审计)
| 方法 | 路径 | 说明 | 能力 |
|---|---|---|---|
| POST | `/patients/:id/dispositions` | 新建处置单(含 AI 建议生成入口) | `disposition:create` |
| GET | `/patients/:id/dispositions` | 列处置单(含动作/跟进/状态) | `disposition:read` |
| POST | `/dispositions/:id/confirm` | 确认中/高风险处置单并执行 | `disposition:confirm`(管理师/医生) |
| POST | `/dispositions/:id/actions/:aid/execute` | 单动作执行/回执 | `disposition:execute` |
| GET | `/patients/:id/worklist` | 聚合待处置队列(预警+到期跟进+情绪+漏测,按优先级) | `patient:read` |
| GET | `/patients/:id/trends?indicator=` | 指标趋势(近 N 次) | `observation:read` |
| POST | `/dispositions/:id/followups` / 评估 | 建/评估跟进项(复测比对) | `disposition:execute` |
| POST | `/patients/:id/referrals` | 发起转诊/会诊 | `referral:create`(管理师/医生) |
| POST | `/referrals/:id/respond` | 医生回复转诊/会诊 | `referral:respond`(医生) |
| POST | `/patients/:id/emotion-checkins` | 孕妇情绪打卡 | `emotion:create`(孕妇/家属代) |
| GET | `/patients/:id/emotion-signals` | 身心信号(医护态势) | `patient:read` |
> RBAC 矩阵新增能力须同步 `0-req`/`auth rbac` 与 5-compliance;端点继续走全局 `JwtAuthGuard + CapabilitiesGuard`。持久化沿用 PG 可切换仓储 + 字段级加密(敏感内容入 enc 列)。
---
## 7. 多端协同
- **孕妇端**:新增"情绪打卡"(首页/任务页轻量入口);复测提醒;处置产生的提醒/教育推送可见。
- **医生端**(医护端内角色门控):转诊/会诊待办与回复;高风险处置确认兜底。
- **家属端**:只读身心状态概览(授权范围内),关怀提醒。
- **一致性**:沿用单一后端真源 + `useAutoRefresh`(焦点/可见性/轮询)近实时收敛;实时推送列 V2。
---
## 8. 数据湖与"增值礼物"(原则 #6 落地)
每条记录都按 `孕妇ID + 孕周/时间戳` 汇流为**纵向数据湖**(即基础构想附录三"孕妇本体时间轴"),统一结构 `TimelineEntry{ 类型, 关联实体ID, 数值/状态, 来源Provenance, 个案阶段, 孕周 }`,覆盖:观测、趋势、预警、处置单与动作、跟进结果、情绪、知识引用、阶段流转。
- **工程含义**:采集即入湖、富含 provenance、按时间轴对齐——这也反向要求处置/跟进/情绪都要结构化留痕(指导本设计的数据建模)。
- **增值礼物(孕期结束加工输出)**:把数据湖加工为送给孕妇的"孕期全纪录"——
- 孕期健康曲线回顾(血糖/血压/体重随孕周)、风险与处置故事线、达标里程碑
- 关键时刻相册/里程碑(建档、首次胎动、各期节点)、情绪旅程回顾
- 可导出的"孕期纪念档案"/数字纪念册
- **约束**:导出须经知情同意、最小化与隐私合规(5-compliance),温度优先、不制造焦虑。
---
## 9. KPI 与度量(让"反馈"可量化)
| 维度 | 指标 |
|---|---|
| 监管有效性 | 预警及时率、预警准确率、**处置闭环率**、**复测达标率**、高风险转诊及时率 |
| 行动效率 | 预警→处置时延、单管理师可管理人数(人机协同提效) |
| 用户依从 | 监测依从率、漏测率、计划执行率、**情绪打卡率** |
| 健康结局 | 血糖/血压达标率、不良结局发生率 |
| 身心体验 | **焦虑量表改善**、满意度、留存 |
---
## 10. 需求映射与新增条目(回写 0-req / 1-prd 建议)
| 新增需求(建议编号) | 描述 | 关联现有 |
|---|---|---|
| REQ-D1 处置单 | WHEN 医护对预警/风险处置,THE 系统 SHALL 支持创建含一个或一组动作的处置单并跟踪状态 | REQ-6(个案)、REQ-10.3(人工兜底) |
| REQ-D2 跟进闭环 | WHEN 处置含复测,THE 系统 SHALL 生成跟进项并按目标参数自动判定达标 | §5.1 闭环 |
| REQ-D3 情绪打卡 | THE 系统 SHALL 提供孕妇情绪自评并派生身心信号供医护处置 | 身心健康 |
| REQ-D4 转诊会诊 | WHEN 高风险/需会诊,THE 系统 SHALL 支持发起转诊/会诊并由医生回复 | REQ-4.3、医生兜底 |
| REQ-D5 执行驾驶舱 | THE 工作台 SHALL 以态势→队列→处置→跟进的主线组织信息与操作 | §5.1/§5.2、3-ui-style |
| REQ-D6 数据湖与礼物 | THE 系统 SHALL 按孕周时间轴沉淀全量数据并支持加工为孕期纪念输出 | 基础构想附录三 |
---
## 11. 分期落地路线
### MVP(第一步,价值最大闭环)
1. 后端:`disposition`(单+动作)、`followup`、通用达标参数表、`referral``emotion` 基础实体与端点;`worklist` 聚合;`trends` 近 N 次。
2. 医护端:执行驾驶舱(态势卡 + 待处置队列 + 处置工作区 + 收口区),处置单创建/确认/执行、跟进达标闭环、转诊/会诊发起与医生回复。
3. 孕妇端:情绪打卡 + 复测提醒。
4. 跑通主线:预警 → 打包处置 → 复测 → 达标闭环;情绪低落 → 关怀处置;高风险 → 会诊/转诊。
### 增强(V2+
- 趋势预测模型(替代规则级趋势)、处置模板库、有效性 KPI 看板、数据湖"增值礼物"导出、游戏化依从、中医辨证调养处置类型。
### 任务大纲(供 2-task 拆解)
- T-D.1 处置单领域模型与端点(后端)
- T-D.2 跟进与达标判定(后端,含通用参数表)
- T-D.3 转诊/会诊 + 医生端协同
- T-D.4 情绪打卡(孕妇端)+ 身心信号(后端/医护端)
- T-D.5 执行驾驶舱页面重构(医护端 master-detail
- T-D.6 待处置队列聚合 + 趋势
- T-D.7 数据湖时间轴归一(为增值礼物打基础)
- T-D.8 测试与跨端联调;回写 0-req/1-prd/2-task
---
## 12. 待确认事项
1. **达标目标参数**:§4.4 通用默认值是否由运营方医生确认/调整(高龄、既往 GDM/PIH 收紧规则)。
2. **会诊/转诊承接**:医生端回复 SLA、转诊网络对接范围(本期到"系统内会诊+转诊建议记录"为止?)。
3. **情绪自评工具**:用轻量 0–10 自评,还是引入标准量表(量表化与焦虑风险权衡,须医学/伦理确认)。
4. **增值礼物形态**:孕期结束输出的具体形式(数字纪念册/导出报告)与隐私同意流程,本期是否仅打地基(数据湖归一)而输出列 V2。
5. **危机信号处置 SOP**:情绪危机/自伤表述的即时人工介入与转介流程(内容安全)。
---
> 确认本设计后:①回写 `0-req-PCM.md`REQ-D1~D6)与 `1-prd-PCM.md`(更新 §5.1 执行流、功能清单、RBAC);②在 `2-task-PCM.md` 落 T-D.* 任务;③按 MVP 开始实现。
+309
View File
@@ -0,0 +1,309 @@
# PCM 孕期监测指标体系(指标字典)
> 文档定位:本文件是孕妇监测**指标体系的单一事实来源(Single Source of Truth**——把基础构想中"数十种指标"的愿景落成结构化、可扩展、可配置的**指标字典**,供分析规则引擎、数据质控、处置达标判定、孕期数据湖共同引用。
>
> 关系:上承 `0-1-基础构想.md`(§5 产品范围、附录2.2 监测指标体系、附录三 孕妇时间轴)、`0-req-PCM.md`REQ-1/REQ-3 监测与分析)、`1-prd-PCM.md`;与 `6-exec-PCM.md`(§4.4 达标参数表引用本字典)、`4-arch-PCM.md`、`5-compliance-PCM.md` 协同。规则阈值与可测项受 `2-task-PCM.md` 阻塞项 **C-2(设备可测指标清单 + 精度 + 认证)** 制约。
>
> 重要约束:本系统定位为**健康管理 / 临床决策支持,非诊断**。本文所有阈值/分段为**通用临床常识占位**,须由运营方专业医生最终确认;可测指标以**设备实际能力**为准(C-2)。
---
## 1. 目的与原则
1. **单一事实来源 + 统一编码**:每个指标在系统中只定义一次,设备数据、规则、知识库、处置、数据湖均引用同一定义(基础构想附录二"先建统一术语表/指标字典")。
2. **孕周分段**:阈值按孕早/中/晚期切换(同一数值在不同孕周判定可能不同)。
3. **个体化收紧**:依据静态风险基线(高龄、既往 GDM/PIH、超重、多胎等)对默认阈值收紧或提高监测频率。
4. **可解释、可追溯**:风险判定可回溯到具体指标、规则、知识依据与观测值。
5. **可配置扩展**:指标与规则以数据驱动(NFR-7),新增指标/调整阈值不改代码(当前 MVP 为硬编码,本文给出向配置化迁移的结构)。
6. **设备能力为准 + 渐进**:先做设备可测、临床价值高的指标(GDM 血糖 + 妊高征血压),随设备清单与数据积累横向扩展。
7. **数据质控前置**:每个指标定义生理合理范围,超范围或缺失不得直接驱动高风险结论。
---
## 2. 指标字典数据结构(实现指引)
每个指标是一条字典记录,建议落为可配置表(替代当前硬编码 `INDICATORS` + `DEFAULT_RULES`):
```
IndicatorDef {
code // 内部唯一键,如 'fasting_glucose'
label // 中文名
unit // 单位
category // metabolic|cardiovascular|anthropometric|nutrition|fetal|tcm|psych|other
source // device | manual | both
plausibleMin // 生理合理下限(质控)
plausibleMax // 生理合理上限(质控)
redFlagRelated // 是否参与红旗组合
standardCode // 对接编码(LOINC/SNOMED,待术语表落定)
status // implemented | rule_pending | device_pending | v2
thresholds: ThresholdByStage[] // 按孕周分段的判定(可空=待填)
}
ThresholdByStage {
stage // any | first(0-13w) | second(14-27w) | third(28w+)
grades: { op, value, level, message }[] // 如 { '>=', 5.1, 'medium', '...' }
}
```
> 达标判定(复测目标)见 `6-exec-PCM.md` §4.4;本文负责"异常/风险阈值"6-exec 负责"处置后回落目标",二者共用同一 `code` 与单位。
---
## 3. 指标分类总览与状态图例
分类(对齐基础构想附录2.2):
| 类别 code | 名称 | 临床意义 |
|---|---|---|
| metabolic | 代谢类 | 血糖系列、糖化血红蛋白——GDM 监测核心 |
| cardiovascular | 心血管类 | 血压、心率——妊娠期高血压/子痫前期核心 |
| anthropometric | 体格/生长 | 体重、增重、BMI |
| nutrition | 营养/微量元素 | 维生素D、铁/血红蛋白、钙等 |
| fetal | 胎儿相关 | 胎动、胎心 |
| tcm | 中医辨证 | 体质、舌象、脉象、证候 |
| psych | 心理/身心 | 情绪自评、焦虑自评 |
| other | 其他生命体征/生化 | 尿蛋白、体温、血氧等 |
状态图例:
| 状态 | 含义 |
|---|---|
| ✅ implemented | 已在后端指标目录且有规则 |
| 🟡 rule_pending | 已在目录、阈值规则待补 |
| 🟠 device_pending | 临床价值明确,待 C-2 设备清单确认可测性 |
| 🔵 v2 | 列入二期(中医辨证、趋势预测等) |
| 🟢 mvp-planned | 本期计划新增(已在 6-exec 设计,未实现) |
---
## 4. 指标明细
> 阈值列:`already in code` 的为后端 `rule-engine.ts` 现值(通用占位);其余为框架占位,标注待确认。所有阈值**待医生确认**,并须补孕周分段(见 §5)。
### 4.1 代谢类 metabolicGDM 核心)
| 编码 | 名称 | 单位 | 合理范围(质控) | 来源 | 现有阈值/规则 | 关联风险 | 状态 |
|---|---|---|---|---|---|---|---|
| fasting_glucose | 空腹血糖 | mmol/L | 140 | both | ≥5.1 中(FG-1) / ≥7.0 高 | GDM | ✅ |
| ogtt_1h | OGTT 1小时血糖 | mmol/L | 140 | both | ≥10.0 中(OGTT1-1) | GDM | ✅ |
| ogtt_2h | OGTT 2小时血糖 | mmol/L | 140 | both | ≥8.5 中(OGTT2-1) | GDM | ✅ |
| postprandial_glucose | 餐后血糖 | mmol/L | 140 | both | 目标<6.7(达标用)**异常规则待补** | GDM | 🟡 |
| hba1c | 糖化血红蛋白 | % | 320 | device | 待补(如≥5.7 关注) | 血糖控制 | 🟠 |
### 4.2 心血管类 cardiovascular(妊高征/子痫前期核心)
| 编码 | 名称 | 单位 | 合理范围 | 来源 | 现有阈值/规则 | 关联风险 | 红旗 | 状态 |
|---|---|---|---|---|---|---|---|---|
| systolic_bp | 收缩压 | mmHg | 50300 | both | ≥140 中(SBP-1) / ≥160 高 | 妊娠期高血压 | 是 | ✅ |
| diastolic_bp | 舒张压 | mmHg | 30200 | both | ≥90 中(DBP-1) / ≥110 高 | 妊娠期高血压 | 是 | ✅ |
| heart_rate | 心率 | bpm | 30250 | both | 阈值待补 | 心血管 | — | 🟡 |
### 4.3 体格/生长 anthropometric
| 编码 | 名称 | 单位 | 合理范围 | 来源 | 现有阈值/规则 | 关联风险 | 状态 |
|---|---|---|---|---|---|---|---|
| weight | 体重 | kg | 30200 | both | 按孕前 BMI 周增区间,**规则待补** | 增重异常/子痫前期信号 | 🟡 |
| bmi | 体质指数(派生) | kg/m² | 1060 | derived | 孕前 BMI 分层(已用于初始风险) | 基线分层 | ✅(基线) |
| weight_gain_rate | 周增重(派生) | kg/周 | -23 | derived | 短期骤增→子痫前期信号(待补) | 子痫前期 | 🟡 |
### 4.4 营养/微量元素 nutrition(待设备)
| 编码 | 名称 | 单位 | 来源 | 临床意义 | 状态 |
|---|---|---|---|---|---|
| vitamin_d_25oh | 维生素D(25-OH) | ng/mL | device | 胎儿骨骼发育、母体免疫 | 🟠 |
| hemoglobin | 血红蛋白 | g/L | device | 贫血筛查 | 🟠 |
| serum_iron | 血清铁 | μmol/L | device | 缺铁 | 🟠 |
| calcium | 血钙 | mmol/L | device | 钙代谢 | 🟠 |
### 4.5 胎儿相关 fetal(待设备)
| 编码 | 名称 | 单位 | 来源 | 临床意义 | 红旗 | 状态 |
|---|---|---|---|---|---|---|
| fetal_movement | 胎动计数 | 次/时段 | manual/device | 胎儿安危 | 是(明显减少/消失) | 🟠 |
| fetal_heart_rate | 胎心率 | bpm | device | 胎儿监护 | 是(异常) | 🟠 |
### 4.6 其他生命体征/生化 other
| 编码 | 名称 | 单位 | 来源 | 临床意义 | 红旗 | 状态 |
|---|---|---|---|---|---|---|
| urine_protein | 尿蛋白 | 定性/mg | device/试纸 | 子痫前期关键指标 | 是(显著阳性) | 🟠 |
| body_temperature | 体温 | ℃ | both | 感染 | — | 🟠 |
| spo2 | 血氧饱和度 | % | device | 缺氧 | — | 🟠 |
### 4.7 中医辨证 tcmV2
| 编码 | 名称 | 类型 | 来源 | 用途 | 状态 |
|---|---|---|---|---|---|
| tcm_constitution | 体质类型 | 枚举 | 问卷/医师 | 辨证调养建议 | 🔵 |
| tcm_tongue | 舌象 | 结构化/图像 | 采集/医师 | 辨证 | 🔵 |
| tcm_pulse | 脉象 | 结构化 | 医师/设备 | 辨证 | 🔵 |
| tcm_syndrome | 证候 | 枚举 | 辨证推理 | 调养方案 | 🔵 |
### 4.8 心理/身心 psych(本期新增,见 6-exec D3
| 编码 | 名称 | 单位 | 来源 | 用途 | 状态 |
|---|---|---|---|---|---|
| mood_self | 情绪自评 | 010 | manual(孕妇端) | 身心信号、关怀处置触发 | 🟢 |
| anxiety_self | 焦虑自评 | 010 | manual(孕妇端) | 身心信号、焦虑改善 KPI | 🟢 |
> 心理类采用轻量自评(非诊断量表),避免量表化加重焦虑;危机信号(持续低分/危机表述)旁路人工即时介入(6-exec §4.6 / 内容安全)。
---
## 5. 孕周分段阈值框架
时间主轴(基础构想附录三):
| 分期 | 孕周 | 监测重点 |
|---|---|---|
| 孕早期 first | 0–13w | 建档、基线、早期风险筛查 |
| 孕中期 second | 1427w | GDM 筛查窗(2428w)、稳定监管 |
| 孕晚期 third | 28–40w+ | 妊高征/子痫前期高发、胎儿监护强化 |
| 产后 postpartum | 分娩后 | 指标恢复、随访、转出 |
现状与缺口:规则引擎结构支持按孕周适配(`appliesTo`),但**当前规则未填分段**——所有阈值不分孕周。需医生给出各指标分段阈值后填充。示例(占位,待确认):
```
fasting_glucose:
any: >=5.1 medium, >=7.0 high # 现状
(建议按孕周细化诊断窗与管理目标)
systolic_bp:
any: >=140 medium, >=160 high # 现状
weight_gain_rate:
second/third: 周增 > 上限 medium # 待医生给区间
```
个体化收紧:当孕妇带高龄/既往 GDM/PIH/超重/多胎等基线因子时,对应指标阈值收紧或监测频率提高(关联 `PatientProfile.initialRiskFactors`)。
---
## 6. 红旗急症指标组合(旁路升级)
红旗不走单指标常规规则,由专门旁路即时升级(REQ-4.3 / 基础构想附录五):
| 红旗规则 | 触发组合 | 动作 |
|---|---|---|
| 子痫前期急症 | (SBP≥160 或 DBP≥110) + (剧烈头痛/视物模糊/上腹痛 之一) | 立即就医/转急诊 + 通知医生 + 升高风险 |
| 胎动异常 | 胎动明显减少 / 消失 | 立即就医提示 + 通知医生 |
| 产科出血 | 阴道大量出血 | 立即急诊 |
| 严重血糖异常 | 极高/极低血糖 + 症状 | 立即处置 + 通知医生 |
| 尿蛋白显著阳性 + 高血压 | 子痫前期信号 | 升级评估 |
---
## 7. 数据质量与质控(前置门)
- **合理范围校验**:超出 `plausibleMin/Max` 标记存疑,不驱动高风险结论(已实现,质控 T-3.4)。
- **来源标记**device / manual,区分可信度;设备需校准与认证(C-2)。
- **缺失/中断**:漏测触发提醒;缺失不外推为正常。
- **多源归一**:同指标多设备/多次测量按 `孕妇ID + 指标code + 时间戳` 归一为时间序列(数据湖)。
---
## 8. 编码与标准
- 建议对接通行医学术语标准:检验项 **LOINC**、临床概念 **SNOMED CT**、诊断 **ICD**(基础构想附录二)。
- 具体编码待统一术语表落定;示例(待核):空腹血糖 LOINC 1558-6、HbA1c 4548-4、收缩压 8480-6、舒张压 8462-4、维生素D(25-OH) 1989-3。
- 目的:设备数据、规则、知识库三方"说同一种语言",支撑互操作与 FHIR 对接(NFR)。
---
## 9. 与分析 / 数据湖 / 处置的关系
```
设备/手填 → 观测值(指标code+值+孕周+来源)
→ [质控] 合理范围/来源
→ [分析] 指标字典阈值(本文) → 风险+预警(可解释)
→ [处置] 6-exec 处置单 → 达标目标(6-exec §4.4,同 code)
→ [跟进] 复测回流 → 比对达标
→ [数据湖] 全部按孕周时间轴归一(孕妇本体) → 增值礼物
```
指标字典是这条链的"词表地基":分析引擎、6-exec 达标参数、数据湖时间轴、知识库指标释义全部引用本文 `code`
---
## 10. 现状快照与落地分期
现状(诚实盘点,对齐代码):
- 后端目录 `INDICATORS`**8 个**fasting_glucose、ogtt_1h、ogtt_2h、postprandial_glucose、systolic_bp、diastolic_bp、weight、heart_rate。
- 有阈值规则的 **5 个**fasting_glucose、ogtt_1h、ogtt_2h、systolic_bp、diastolic_bp。
- **无规则**postprandial_glucose、weight、heart_rate。
- **孕周分段**:结构支持、未填充。
- 其余(HbA1c、维生素D、铁/Hb、钙、胎动、胎心、尿蛋白、体温、血氧、中医、情绪)**未入系统**。
分期:
| 阶段 | 范围 |
|---|---|
| MVP(现有 + 本期补) | 巩固血糖+血压闭环;补 postprandial/weight/heart_rate 规则;新增情绪/焦虑自评;指标字典配置化(去硬编码) |
| 待 C-2 解锁 | 维生素D、铁/Hb、钙、胎动、胎心、尿蛋白、体温、血氧(取决于设备可测性+认证+接口) |
| V2 | 中医辨证维度、孕周分段阈值精细化、趋势/变化率时序规则、趋势预测模型 |
---
## 11. 依赖与待确认
1. 🔴 **C-2 设备可测指标清单 + 精度 + 认证**:决定 nutrition/fetal/other 多数指标能否落地(阻塞)。
2. 🟠 **各指标孕周分段阈值与异常判定**:须医生按指南给出(阻塞规则填充)。
3. 🟠 **个体化收紧参数**:哪些基线因子、收紧多少。
4. 🟡 **统一术语表/编码**LOINC/SNOMED 具体映射。
5. 🟡 **时序规则参数**:趋势/变化率判定(V2)。
---
## 12. 需求映射(建议回写 0-req / 1-prd
| 建议条目 | 描述 | 关联 |
|---|---|---|
| REQ-I1 指标字典 | THE 系统 SHALL 以可配置指标字典统一定义监测指标(编码/单位/范围/分段阈值/来源) | REQ-1/REQ-3、基础构想附录二 |
| REQ-I2 孕周分段 | WHERE 指标判定,THE 系统 SHALL 按孕周分段并支持个体化收紧 | REQ-3 约束 |
| REQ-I3 质控门 | IF 观测超合理范围或缺失,THE 系统 SHALL 标记存疑且不驱动高风险结论 | T-3.4 |
| REQ-I4 编码标准 | THE 系统 SHOULD 对接 LOINC/SNOMED 以支持互操作 | NFR/FHIR |
## 13. 数据汇集页「健康档案」UI 规格
> 孕妇详情第二页(与「工作台」用**胶囊 Tab** 切换,工作台为默认;见 `6-exec-PCM.md` §5)。本页是**指标字典 + 孕妇本体数据湖的可视化**——数据视角,回答"这位孕妇全方位数据全貌"。后端实体/接口不变,本页为前端信息架构。
### 13.1 信息架构与线框
```
┌─────────────────────────────────────────────────────────────────────────┐
│ ← 返回 王芳 PCM-000003 ( 工作台 ▮ 健康档案 ) ← 胶囊Tab │
│ ┌─ 指标概览(按分类卡片:最新值 + 达标状态 + 趋势↑↓) ───────────────────┐ │
│ │ 代谢:空腹血糖 5.6↑(达标线5.1) | 心血管:收缩压 150↑ 舒张压 95↑ │ │
│ │ 体格:体重 +0.6kg/周 | 身心:情绪 6/10 → │ │
│ │ 营养/胎儿:待设备(C-2) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ ┌── 选中指标趋势(孕周时间轴) ──┐ ┌── 全部观测记录(可筛选) ───────────┐ │
│ │ 收缩压 近12周折线 + 阈值带 │ │ 指标/孕周/来源/质控 筛选 │ │
│ │ 达标线 / 红旗线 │ │ 150 mmHg·孕31周·手填·合格 … │ │
│ └────────────────────────────┘ └──────────────────────────────────────┘ │
│ ┌─ 个案动态时间线(全过程留痕,从工作台迁入) ─────────────────────────┐ │
│ │ 采集→预警→流转→计划→提醒→处置→跟进,最新在上,可按类型筛选 │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ ┌─ 身心曲线 ─┐ ┌─ 静态档案(基线/孕产史/风险因素) ─┐ ┌ 数据湖·增值礼物(V2) ┐│
│ └────────────┘ └──────────────────────────────────┘ └────────────────────┘│
└─────────────────────────────────────────────────────────────────────────┘
```
### 13.2 各区职责
| 区 | 职责 | 数据来源 | 复用组件 |
|---|---|---|---|
| 指标概览 | 按分类(§3)展示各指标最新值 + 达标状态 + 趋势箭头;未采集/待设备标注 | 指标字典 + 最新观测 | 新增 IndicatorOverview |
| 指标趋势 | 选中指标的孕周时间轴折线 + 阈值/达标线 | `/trends` + 字典阈值 | 新增 TrendChart |
| 全部观测记录 | 所有观测,按指标/孕周/来源/质控筛选 | observations | 复用 ObservationsPanel(迁入并增强筛选) |
| 个案动态时间线 | 全过程留痕(采集/预警/流转/计划/提醒/处置/跟进) | 汇流事件 | 复用 CaseTimeline(从工作台迁入) |
| 身心曲线 | 情绪/焦虑自评趋势 | emotion-checkins | 新增 |
| 静态档案 | 基线、孕产史、既往史、风险因素 | PatientProfile | 复用档案头扩展 |
| 数据湖·增值礼物 | 孕期全纪录入口(V2) | 数据湖 | V2 |
### 13.3 与「工作台」的互链与边界
- 工作台「分析」中点某指标 → 跳本页该指标趋势;本页发现异常 → 一键回工作台发起处置。
- **不重复**:工作台呈现"要处理的事"(预警作为待办、处置、跟进);健康档案呈现"完整数据"(观测、趋势、时间线)。同一条数据两视角各取所需。
- 响应式:窄屏分类卡片与图表上下堆叠;保持医护端专业中性风、可达性达标(3-ui-style §8)。
---
> 确认本字典框架后:①将指标体系从硬编码迁移为配置化字典(后端 `analysis`);②医生填充孕周分段阈值;③随 C-2 设备清单扩展可测指标;④回写 0-reqREQ-I1~I4)与 1-prd 功能清单/约束。
+59
View File
@@ -0,0 +1,59 @@
---
inclusion: always
---
# AI Coding 工作流规则
本规则定义从需求到交付的标准化协作流程。所有涉及"做一个项目 / 实现一个功能"的请求,均遵循以下五个阶段,逐阶段产出文档并在每个关键节点等待用户确认后再推进。
## 命名约定
- 项目英文缩写记为 `XXX`(如 IPTV、AVCC),由用户提供或与用户确认。
- 文档统一命名并置于项目根目录(或用户指定目录):
- `0-req-XXX.md` — 需求与目标文档
- `1-prd-XXX.md` — 产品需求文档(PRD
- `2-task-XXX.md` — 开发任务文档
## 阶段流程
### 阶段 1:接收需求与目标
- 用户给出需求和目标描述,可以是一段文字,也可以是一份 md 文档。
- 我先完整读取并理解用户的需求与目标;若有歧义或关键信息缺失,先向用户澄清,不臆测。
### 阶段 2:生成 `0-req-XXX.md`(需求文档)
- 基于用户的需求与目标,生成 `0-req-XXX.md`
- 内容应包含:引言、术语表、角色定义、功能性需求(采用 EARS 格式:WHEN/IF/WHILE/WHERE/THE...SHALL)、非功能性需求、关键约束与假设。
- 生成后**必须请用户检查确认**。未确认前不进入下一阶段。
- 用户提出修改意见时,更新文档并再次请其确认,直到通过。
### 阶段 3:生成 `1-prd-XXX.md`(产品需求文档)
- 仅在 `0-req-XXX.md` 确认通过后进行。
- 基于需求文档生成 `1-prd-XXX.md`
- 内容应包含:产品概述与定位、目标与成功指标、用户画像与核心场景(每个场景标注"痛点解法")、功能清单与优先级(MoSCoW,并映射回需求编号)、关键流程、角色权限矩阵、版本规划、非功能性要求、依赖与风险。
- 生成后**必须请用户检查确认**。未确认前不进入下一阶段。
### 阶段 4:生成 `2-task-XXX.md`(开发任务文档)
- 仅在 `1-prd-XXX.md` 确认通过后进行。
- 生成 `2-task-XXX.md`,用于指导开发,包含详细的工作任务分解。
- 任务要求:
- 以可勾选清单(`- [ ]`)组织,编号清晰,粒度可执行可验证。
- 每个任务标注:目标、对应的需求/PRD 条目、验收标准、依赖关系。
- 区分优先级与阶段(如 MVP / 二期 / 三期)。
- **开发过程中根据实际进度持续更新该文档**(勾选完成项、记录变更、补充新任务)。
- 生成后**必须请用户确认**。
### 阶段 5:按任务文档执行开发
- 仅在 `2-task-XXX.md` 确认通过后开始编码。
- 严格按确认的任务文档推进开发工作。
- 每完成一个任务或一组任务:
- 进行相应的测试(构建、单元测试、必要时集成测试)。
- 更新 `2-task-XXX.md` 的任务状态与进度记录。
- 与用户交互、汇报进展、确认下一步。
- 持续推进,直至完成全部任务。
## 通用约束
- 每个阶段的产出都要等待用户确认,不得跨阶段抢跑。
- 文档之间保持可追溯:PRD 功能映射回需求编号,任务映射回需求/PRD 条目。
- 当上游文档(需求或 PRD)发生变更时,同步更新下游文档,保持一致。
- 用户的语言即回复与文档的语言(默认中文)。
+19
View File
@@ -0,0 +1,19 @@
# PCM 部署编排环境变量示例(复制为 .env;勿提交真实密钥)
# 后端鉴权令牌签名密钥(生产必须为强随机值)
AUTH_SECRET=change-me-to-a-strong-random-secret
# 字段级加密密钥(AES-256-GCM32 字节 hex64/base64;生产经 KMS 注入)
# 生成:node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
FIELD_ENCRYPTION_KEY=
# PostgreSQLdb 服务)
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=pcm
# 各服务对外端口(可按环境调整)
BACKEND_PORT=3000
PATIENT_PORT=8081
ADMIN_PORT=8082
FAMILY_PORT=8083
+29
View File
@@ -0,0 +1,29 @@
# PCM 部署便捷命令
# 用法:make build | up | down | logs | ps | deploy-staging
COMPOSE ?= docker compose
.PHONY: build up down restart logs ps health deploy-staging
build: ## 构建全部镜像
$(COMPOSE) build
up: ## 构建并后台启动全部服务
$(COMPOSE) up -d --build
down: ## 停止并移除容器
$(COMPOSE) down
restart: down up ## 重启
logs: ## 跟踪日志
$(COMPOSE) logs -f
ps: ## 查看服务状态
$(COMPOSE) ps
health: ## 探活后端
@curl -fsS http://localhost:$${BACKEND_PORT:-3000}/api/health && echo
deploy-staging: ## 一键部署到 staging(构建+启动+探活)
./scripts/deploy-staging.sh
+69
View File
@@ -0,0 +1,69 @@
# PCM 孕产个案管理平台
Monorepo。文档见上级目录:`0-req-PCM.md`(需求)、`1-prd-PCM.md`PRD)、`2-task-PCM.md`(任务)、`3-ui-style-PCM.md`UI 风格)、`4-arch-PCM.md`(架构)。
## 结构
- `backend/` — NestJS API(核心后端,全局鉴权/授权守卫)
- `patient-app/` — 孕妇端(Vite+React 移动 WebAI 对话优先)
- `admin-web/` — 医护/运营端(React PC 工作台 + 知识库/审计)
- `family-app/` — 家属端(轻量移动 Web,只读状态+提醒+问答)
> 移动端按 `4-arch-PCM.md` 最终可经 Taro 移植为微信小程序;当前以可运行可验证的 Web 实现。
## 本地开发
后端:
```bash
cd backend
npm install
cp .env.example .env # 按需填写 AUTH_SECRET 等
npm run start:dev # 开发模式
npm test # 单元测试
```
前端(任一应用,开发服务器自带 /api → 后端代理):
```bash
cd patient-app # 或 admin-web / family-app
npm install
npm run dev
```
健康检查:`GET http://localhost:3000/api/health`
示例数据(seed,需 PostgreSQL):
```bash
cd backend
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/pcm \
FIELD_ENCRYPTION_KEY=<32字节hex> npm run seed
```
清空领域数据并写入真实示例:账号(caseManager/physician/operator/admin/mama01/family01,密码 pcm12345)、知识库、12 名孕妇(含编号 PCM-000001…、不同风险分层,部分含预警/个案/照护计划/提醒)。
## 容器化部署(dev/staging
前置:Docker + Docker Compose。
一键部署:
```bash
cp .env.example .env # 设置 AUTH_SECRET 与端口
make up # 构建并启动全部服务(= docker compose up -d --build
# 或:make deploy-staging # 构建 + 启动 + 等待后端健康 + 探活前端
```
默认访问地址:
- 后端 API`http://localhost:3000/api/health`
- 孕妇端:`http://localhost:8081/`
- 医护/运营端:`http://localhost:8082/`
- 家属端:`http://localhost:8083/`
常用命令:`make ps`(状态)、`make logs`(日志)、`make down`(停止)。
镜像构成:
- 后端:多阶段构建(编译 → 仅生产依赖,非 root 运行,含健康检查)。
- 三前端:Vite 构建产物由 nginx 托管,SPA 路由回退,`/api` 反向代理到 `backend` 服务。
环境分层:
- dev:各应用 `npm run dev`Vite 代理 `/api` 到本地后端。
- staging`make deploy-staging`(本编排)。
- prod:同编排为基线,需追加 TLS 终止、密钥管理、PostgreSQL 持久化与监控。
> 持久化:编排含 PostgreSQL `db` 服务,后端经 `DATABASE_URL` 连接并持久化(auth/audit 已落库,启动幂等建表)。不设置 `DATABASE_URL` 时后端回退内存仓储(dev/测试)。字段级加密密钥经 `FIELD_ENCRYPTION_KEY` 注入(用于敏感健康字段,随 patient/observation 迁移启用)。
## 安全约定
- 密钥仅放本地 `.env`,不提交仓库;生产 `AUTH_SECRET` 必须为强随机值。
- 全局守卫:JwtAuthGuard(令牌校验)+ CapabilitiesGuardRBAC 能力授权,越权 403 并审计)。
- 孕产+胎儿数据按最敏感个人信息处理(加密、最小化、分级访问、审计)。
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
.env
.env.local
*.log
.git
.DS_Store
Dockerfile
.dockerignore
+16
View File
@@ -0,0 +1,16 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs', 'vite.config.ts'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
};
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
dist-ssr
*.local
.DS_Store
.vite
+12
View File
@@ -0,0 +1,12 @@
# PCM 医护/运营端静态镜像:Vite 构建 → nginx 托管
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+71
View File
@@ -0,0 +1,71 @@
# PCM 医护/运营端工作台 (admin-web · T-8.3 / T-8.4)
孕产个案管理平台 **医护端 + 运营/管理端**(个案管理师 / 医生 / 运营 / 管理员)。React Web
信息密度高的专业后台,对接 `backend` 的 caseflow / analysis / ai / redflag / careplan / knowledge / audit 接口。
按登录角色(RBAC)展示对应功能。
UI 依据 `../../3-ui-style-PCM.md` §8:复用品牌主色与字体,但采用中性专业风(不套用孕妇端暖萌风)。
## 技术栈
- Vite + React 18 + TypeScript
- react-router-dom 路由
- 原生 CSS + Design Tokens(专业主题,与孕妇端共享品牌色)
## 开发
```bash
npm install
npm run dev # http://localhost:5174 /api 代理到后端 (默认 http://localhost:3000)
```
先启动后端:在 `../backend` 执行 `npm run start`
## 脚本
- `npm run dev` / `npm run build` / `npm run lint` / `npm run preview`
## 功能(T-8.3
| 模块 | 说明 | 映射 |
|------|------|------|
| 登录 | 医护(个案管理师/医生)登录注册;非医护角色被拒绝进入 | REQ-11、T-2.1 |
| 工作列表 | 在管孕妇列表,按风险排序、搜索、风险筛选 | REQ-2、REQ-3.2 |
| 个案工作台·档案 | 孕妇档案摘要(孕周/预产期/BMI/风险因素/初始与当前风险) | REQ-2 |
| 个案工作台·流程 | 状态机阶段进度,合法流转推进,指派管理师,流转记录 | REQ-6.1/6.2 |
| 个案工作台·预警 | 预警列表,分级 + 可解释 + 规则溯源 | REQ-3.3、NFR-3 |
| 个案工作台·观测 | 观测记录表(指标/数值/孕周/质控状态) | REQ-1 |
| 个案工作台·AI 建议 | 基于风险与未处理预警生成处置建议 + 依据;**高/中风险须人工确认方可采纳,不自动执行** | REQ-10.2/10.3/10.4 |
| 个案工作台·照护计划 | 制定(目标/干预/随访频率)与查看 | REQ-6.3 |
| 个案工作台·红旗检查 | 提交症状/体征快照做红旗急症检查,命中即通知并升级个案 | REQ-5 |
## 运营 / 管理端功能(T-8.4)
| 模块 | 角色 | 说明 | 映射 |
|------|------|------|------|
| 知识库管理 | 运营/管理/医护 | 录入知识条目(分类/标题/内容/关键词/来源/权威级别)、检索与分类过滤、问答测试(验证溯源与不超纲) | REQ-7.1、knowledge:write |
| 审计日志 | 管理员 | 关键操作留痕查询,按操作者/动作过滤 | NFR-1/9、audit:read |
知识库是孕妇端 RAG 问答的**内容来源**:运营录入条目后,孕妇端/工作台问答即可检索并附溯源作答;
无依据时明确告知不超纲。「问答测试」面板让运营在录入后即时验证效果。
## 人工兜底(REQ-10.3
AI 决策建议面板对高/中风险个案显式标注「需人工确认」,建议默认**不执行**;
医护点击「确认采纳」后才记录人工确认(操作者 + 时间),确保系统不自动执行临床决策。
## 角色门控
UI 按 `backend` RBAC 矩阵门控操作:
- 流转推进 / 照护计划:个案管理师、医生
- 指派给我:个案管理师
- 红旗检查:个案管理师
后端为最终授权边界;UI 门控用于减少误操作。
## 对接的接口
`POST /auth/login|register``GET /patients``/patients/:id``/patients/:id/observations`
`/patients/:id/alerts``POST/GET /patients/:id/case``POST /cases/:id/advance|assign`
`POST/GET /cases/:id/care-plans``GET /ai/patients/:id/recommendation`
`POST /patients/:id/redflag-check``POST/GET /knowledge``GET /knowledge/ask``GET /audit`
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PCM 个案工作台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
# 医护/运营端 nginxSPA 客户端路由回退 + /api 反向代理到后端
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "pcm-admin-web",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "PCM 孕产个案管理平台 · 医护端 PC 工作台(个案管理师/医生)。",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --max-warnings 0",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"lucide-react": "^0.456.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.3.2",
"eslint": "^8.57.1",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.12",
"typescript": "^5.5.4",
"vite": "^5.4.8"
}
}
+82
View File
@@ -0,0 +1,82 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { AuthProvider, useAuth } from './auth/AuthContext';
import { ToastProvider } from './components/Toast';
import { Layout } from './components/Layout';
import { LoginPage } from './pages/LoginPage';
import { WorklistPage } from './pages/WorklistPage';
import { CaseWorkbenchPage } from './pages/CaseWorkbenchPage';
import { KnowledgePage } from './pages/KnowledgePage';
import { AuditPage } from './pages/AuditPage';
import { can, type Action } from './lib/rbac';
function Gate({ children }: { children: JSX.Element }): JSX.Element {
const { user, ready } = useAuth();
if (!ready) {
return <div style={{ padding: 40 }}></div>;
}
if (!user) return <Navigate to="/login" replace />;
return children;
}
function RoleRoute({ action, children }: { action: Action; children: JSX.Element }): JSX.Element {
const { user } = useAuth();
if (!can(user?.role, action)) return <Navigate to="/worklist" replace />;
return children;
}
function PublicOnly({ children }: { children: JSX.Element }): JSX.Element {
const { user, ready } = useAuth();
if (!ready) {
return <div style={{ padding: 40 }}></div>;
}
if (user) return <Navigate to="/worklist" replace />;
return children;
}
export function App(): JSX.Element {
return (
<AuthProvider>
<ToastProvider>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<Routes>
<Route
path="/login"
element={
<PublicOnly>
<LoginPage />
</PublicOnly>
}
/>
<Route
element={
<Gate>
<Layout />
</Gate>
}
>
<Route path="/worklist" element={<WorklistPage />} />
<Route path="/patients/:patientId" element={<CaseWorkbenchPage />} />
<Route
path="/knowledge"
element={
<RoleRoute action="knowledge:write">
<KnowledgePage />
</RoleRoute>
}
/>
<Route
path="/audit"
element={
<RoleRoute action="audit:read">
<AuditPage />
</RoleRoute>
}
/>
</Route>
<Route path="*" element={<Navigate to="/worklist" replace />} />
</Routes>
</BrowserRouter>
</ToastProvider>
</AuthProvider>
);
}
+221
View File
@@ -0,0 +1,221 @@
// 轻量 fetch 客户端:统一前缀、令牌注入、错误处理。
import type {
Alert,
AuthResult,
CarePlan,
CreateCarePlanInput,
Observation,
PatientSummary,
PregnancyCase,
Recommendation,
RedFlagResult,
RedFlagSnapshot,
Reminder,
RiskLevel,
CaseStage,
AuditEntry,
CreateKnowledgeInput,
KnowledgeCategory,
KnowledgeItem,
QaAnswer,
Disposition,
Referral,
ReferralType,
ReferralUrgency,
EmotionCheckin,
WorklistItem,
IndicatorTrendResult,
} from './types';
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api';
let authToken: string | null = null;
export function setAuthToken(token: string | null): void {
authToken = token;
}
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers);
headers.set('Content-Type', 'application/json');
if (authToken) {
headers.set('Authorization', `Bearer ${authToken}`);
}
let res: Response;
try {
res = await fetch(`${API_BASE}${path}`, { ...init, headers });
} catch {
throw new ApiError(0, '无法连接服务器,请稍后再试');
}
if (!res.ok) {
let message = `请求失败 (${res.status})`;
try {
const body = (await res.json()) as { message?: string | string[] };
if (body?.message) {
message = Array.isArray(body.message) ? body.message.join('') : body.message;
}
} catch {
// 忽略非 JSON 响应体
}
throw new ApiError(res.status, message);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
login: (username: string, password: string): Promise<AuthResult> =>
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
register: (body: {
username: string;
password: string;
role: 'case_manager' | 'physician' | 'operator' | 'admin';
}): Promise<AuthResult> =>
request('/auth/register', { method: 'POST', body: JSON.stringify(body) }),
// 孕妇 / 档案
listPatients: (): Promise<PatientSummary[]> => request('/patients'),
getPatient: (id: string): Promise<PatientSummary> => request(`/patients/${id}`),
listObservations: (patientId: string): Promise<Observation[]> =>
request(`/patients/${patientId}/observations`),
listAlerts: (patientId: string): Promise<Alert[]> => request(`/patients/${patientId}/alerts`),
listReminders: (patientId: string): Promise<Reminder[]> =>
request(`/patients/${patientId}/reminders`),
// 个案
openCase: (patientId: string, riskLevel?: RiskLevel): Promise<PregnancyCase> =>
request(`/patients/${patientId}/case`, {
method: 'POST',
body: JSON.stringify({ riskLevel }),
}),
getCase: (patientId: string): Promise<PregnancyCase> => request(`/patients/${patientId}/case`),
advanceCase: (caseId: string, to: CaseStage, reason?: string): Promise<PregnancyCase> =>
request(`/cases/${caseId}/advance`, { method: 'POST', body: JSON.stringify({ to, reason }) }),
assignManager: (caseId: string, caseManagerId: string): Promise<PregnancyCase> =>
request(`/cases/${caseId}/assign`, {
method: 'POST',
body: JSON.stringify({ caseManagerId }),
}),
// 照护计划
createCarePlan: (caseId: string, input: CreateCarePlanInput): Promise<CarePlan> =>
request(`/cases/${caseId}/care-plans`, { method: 'POST', body: JSON.stringify(input) }),
listCarePlans: (caseId: string): Promise<CarePlan[]> => request(`/cases/${caseId}/care-plans`),
// AI 决策建议
getRecommendation: (patientId: string): Promise<Recommendation> =>
request(`/ai/patients/${patientId}/recommendation`),
// 红旗急症检查
redflagCheck: (patientId: string, snapshot: RedFlagSnapshot): Promise<RedFlagResult> =>
request(`/patients/${patientId}/redflag-check`, {
method: 'POST',
body: JSON.stringify(snapshot),
}),
// 知识库(运营端)
listKnowledge: (params?: { q?: string; category?: KnowledgeCategory }): Promise<KnowledgeItem[]> => {
const qs = new URLSearchParams();
if (params?.q) qs.set('q', params.q);
if (params?.category) qs.set('category', params.category);
const suffix = qs.toString();
return request(`/knowledge${suffix ? `?${suffix}` : ''}`);
},
createKnowledge: (input: CreateKnowledgeInput): Promise<KnowledgeItem> =>
request('/knowledge', { method: 'POST', body: JSON.stringify(input) }),
askKnowledge: (q: string): Promise<QaAnswer> =>
request(`/knowledge/ask?${new URLSearchParams({ q }).toString()}`),
// 审计(管理端)
listAudit: (params?: { actorId?: string; action?: string }): Promise<AuditEntry[]> => {
const qs = new URLSearchParams();
if (params?.actorId) qs.set('actorId', params.actorId);
if (params?.action) qs.set('action', params.action);
const suffix = qs.toString();
return request(`/audit${suffix ? `?${suffix}` : ''}`);
},
// 待处置队列 & 趋势 (T-D.5)
getWorklist: (): Promise<WorklistItem[]> => request('/worklist'),
getTrends: (patientId: string, indicator: string, limit = 10): Promise<IndicatorTrendResult> =>
request(`/trends?${new URLSearchParams({ patientId, indicator, limit: String(limit) }).toString()}`),
// 处置单 (T-D.1 / T-D.2)
listDispositions: (patientId: string): Promise<Disposition[]> =>
request(`/patients/${patientId}/dispositions`),
createDisposition: (
patientId: string,
body: {
caseId: string;
sourceType: string;
sourceId?: string;
title: string;
riskLevelAtCreation: RiskLevel;
actions: { kind: string; description: string }[];
supersedesId?: string;
},
): Promise<Disposition> =>
request(`/patients/${patientId}/dispositions`, {
method: 'POST',
body: JSON.stringify(body),
}),
confirmDisposition: (id: string): Promise<Disposition> =>
request(`/dispositions/${id}/confirm`, { method: 'POST' }),
executeDispositionAction: (
dispositionId: string,
actionId: string,
body?: { linkedEntityId?: string; resultNote?: string },
): Promise<Disposition> =>
request(`/dispositions/${dispositionId}/actions/${actionId}/execute`, {
method: 'POST',
body: JSON.stringify(body ?? {}),
}),
closeDisposition: (id: string, outcome: string): Promise<Disposition> =>
request(`/dispositions/${id}/close`, {
method: 'POST',
body: JSON.stringify({ outcome }),
}),
// 转诊与会诊 (T-D.3)
listReferrals: (patientId: string): Promise<Referral[]> =>
request(`/referrals/patient/${patientId}`),
listDoctorReferrals: (): Promise<Referral[]> => request('/referrals/doctor'),
createReferral: (body: {
patientId: string;
dispositionId?: string | null;
type: ReferralType;
urgency: ReferralUrgency;
toDoctorId: string;
clinicalSummary: string;
reason?: string;
}): Promise<Referral> =>
request('/referrals', {
method: 'POST',
body: JSON.stringify(body),
}),
acceptReferral: (id: string): Promise<Referral> =>
request(`/referrals/${id}/accept`, { method: 'POST' }),
respondReferral: (id: string, reply: string, status?: string): Promise<Referral> =>
request(`/referrals/${id}/respond`, {
method: 'POST',
body: JSON.stringify({ reply, status }),
}),
// 情绪打卡 (T-D.4)
listEmotions: (patientId: string): Promise<EmotionCheckin[]> =>
request(`/emotions/patient/${patientId}`),
};
+355
View File
@@ -0,0 +1,355 @@
// 与后端契约一致的类型(镜像 backend/src/modules/*)。医护端使用部分。
export type Role = 'patient' | 'family' | 'case_manager' | 'physician' | 'operator' | 'admin';
export type RiskLevel = 'low' | 'medium' | 'high';
export type Trimester = 'first' | 'second' | 'third';
export interface AuthUser {
id: string;
username: string;
role: Role;
consentSigned: boolean;
}
export interface AuthResult {
token: string;
user: AuthUser;
}
export interface PatientSummary {
id: string;
patientNo: string;
name: string;
age: number;
heightCm?: number;
prePregnancyWeightKg?: number;
prePregnancyBmi?: number;
lmp: string;
edd: string;
multipleGestation: boolean;
historyGdm: boolean;
historyPih: boolean;
adversePregnancyHistory: boolean;
chronicConditions: boolean;
initialRiskLevel: RiskLevel;
initialRiskFactors: string[];
createdAt: string;
gestationalWeeks: number;
gestationalDays: number;
trimester: Trimester;
}
export type QcStatus = 'accepted' | 'rejected';
export interface Observation {
id: string;
patientId: string;
indicator: string;
value: number;
unit: string;
measuredAt: string;
source: 'manual' | 'device';
qcStatus: QcStatus;
qcFlags: string[];
gestationalWeeks: number;
createdAt: string;
}
export type AlertStatus = 'open' | 'acknowledged' | 'resolved';
export interface Alert {
id: string;
patientId: string;
observationId: string;
indicator: string;
value: number;
level: RiskLevel;
ruleIds: string[];
messages: string[];
status: AlertStatus;
createdAt: string;
}
// 个案
export type CaseStage =
| 'screening'
| 'assessment'
| 'risk_stratification'
| 'planning'
| 'implementation'
| 'monitoring'
| 'evaluation'
| 'transition';
export type CaseStatus = 'open' | 'closed';
export interface CaseEvent {
at: string;
from: CaseStage;
to: CaseStage;
reason: string;
}
export interface PregnancyCase {
id: string;
patientId: string;
caseManagerId: string | null;
stage: CaseStage;
status: CaseStatus;
riskLevel: RiskLevel;
history: CaseEvent[];
createdAt: string;
updatedAt: string;
}
// 照护计划
export type InterventionKind = 'clinical' | 'lifestyle' | 'habit';
export interface Intervention {
kind: InterventionKind;
description: string;
}
export type CarePlanStatus = 'active' | 'archived';
export interface CarePlan {
id: string;
caseId: string;
patientId: string;
goals: string[];
interventions: Intervention[];
followUpFrequency: string;
status: CarePlanStatus;
createdAt: string;
updatedAt: string;
}
export interface CreateCarePlanInput {
goals: string[];
interventions: Intervention[];
followUpFrequency: string;
}
// AI 决策建议
export interface Recommendation {
actions: string[];
requiresHumanConfirmation: boolean;
rationale: string[];
}
// 红旗急症
export type Symptom =
| 'severe_headache'
| 'visual_disturbance'
| 'epigastric_pain'
| 'reduced_fetal_movement'
| 'vaginal_bleeding'
| 'severe_edema';
export interface RedFlagSnapshot {
systolicBp?: number;
diastolicBp?: number;
fastingGlucose?: number;
symptoms?: Symptom[];
}
export interface RedFlagHit {
ruleId: string;
message: string;
advice: string;
}
export interface RedFlagResult {
triggered: boolean;
hits: RedFlagHit[];
patientAdvice: string | null;
}
// 知识库
export type KnowledgeCategory = 'guideline' | 'indicator_reference' | 'intervention' | 'tcm';
export type AuthorityLevel = 'authoritative' | 'reference' | 'self';
export interface KnowledgeItem {
id: string;
category: KnowledgeCategory;
title: string;
content: string;
keywords: string[];
source: string;
authority: AuthorityLevel;
createdAt: string;
}
export interface CreateKnowledgeInput {
category: KnowledgeCategory;
title: string;
content: string;
keywords?: string[];
source: string;
authority?: AuthorityLevel;
}
export interface KnowledgeCitation {
id: string;
title: string;
source: string;
authority: AuthorityLevel;
}
export interface QaAnswer {
grounded: boolean;
answer: string;
citations: KnowledgeCitation[];
}
// 提醒 / 随访
export type ReminderType =
| 'exercise'
| 'rest'
| 'water'
| 'medication'
| 'checkup'
| 'measurement';
export interface Reminder {
id: string;
patientId: string;
type: ReminderType;
/** 实际下发的类型(可能因风险调整,如 exercise→rest */
effectiveType: ReminderType;
message: string;
/** 是否因风险被调整 */
adjustedForRisk: boolean;
scheduledAt: string;
createdAt: string;
}
// 审计
export interface AuditEntry {
id: string;
actorId: string;
action: string;
target?: string;
at: string;
}
// 处置单 (Disposition) 与跟进 (FollowUp)
export type DispositionStatus = 'draft' | 'pending_confirmation' | 'executing' | 'following_up' | 'closed';
export interface DispositionAction {
kind: string; // 'recheck' | 'referral' | 'medication' | 'lifestyle'
description: string;
linkedEntityId?: string | null;
}
export interface Disposition {
id: string;
caseId: string;
patientId: string;
sourceType: 'alert' | 'redflag' | 'emotion' | 'routine';
status: DispositionStatus;
riskLevel: RiskLevel;
requiresConfirmation: boolean;
closureOutcome?: string | null;
supersedesId?: string | null;
createdBy: string;
confirmedBy?: string | null;
title: string;
actions: DispositionAction[];
createdAt: string;
updatedAt: string;
closedAt?: string | null;
}
export type FollowUpStatus = 'pending' | 'due' | 'evaluated';
export type FollowUpOutcome = 'met' | 'not_met';
export interface FollowUp {
id: string;
dispositionId: string;
patientId: string;
indicator: string;
targetOperator: string;
targetValue: number;
windowDays: number;
dueAt: string;
status: FollowUpStatus;
outcome?: FollowUpOutcome | null;
evaluatedObservationId?: string | null;
evaluatedAt?: string | null;
createdAt: string;
}
// 转诊与会诊 (Referral)
export type ReferralType = 'referral' | 'consult';
export type ReferralStatus = 'pending' | 'accepted' | 'responded' | 'completed' | 'declined';
export type ReferralUrgency = 'routine' | 'urgent' | 'emergency';
export interface Referral {
id: string;
patientId: string;
dispositionId: string | null;
type: ReferralType;
status: ReferralStatus;
urgency: ReferralUrgency;
fromManagerId: string;
toDoctorId: string;
clinicalSummary: string;
doctorReply?: string | null;
reason?: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateReferralInput {
patientId: string;
dispositionId?: string | null;
type: ReferralType;
urgency: ReferralUrgency;
toDoctorId: string;
clinicalSummary: string;
reason?: string;
}
// 情绪打卡 (Emotion)
export type EmotionStatus = 'normal' | 'concerning' | 'crisis';
export interface EmotionCheckin {
id: string;
patientId: string;
score: number;
status: EmotionStatus;
note: string;
createdAt: string;
}
// 待处置队列项 (Worklist)
export type WorklistItemType = 'alert' | 'followup' | 'emotion' | 'referral';
export type PriorityLevel = 'high' | 'medium' | 'low';
export interface WorklistItem {
id: string;
patientId: string;
patientName: string;
type: WorklistItemType;
title: string;
priority: PriorityLevel;
status: string;
sourceId: string;
createdAt: string;
}
export interface TrendPoint {
observationId: string;
value: number;
measuredAt: string;
}
export interface IndicatorTrendResult {
patientId: string;
indicator: string;
points: TrendPoint[];
direction: 'up' | 'down' | 'stable';
latestValue: number | null;
}
@@ -0,0 +1,91 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { api, setAuthToken } from '../api/client';
import type { AuthUser } from '../api/types';
import { clearSession, loadSession, saveSession, type Session } from './session';
interface AuthContextValue {
user: AuthUser | null;
ready: boolean;
login: (username: string, password: string) => Promise<AuthUser>;
register: (input: {
username: string;
password: string;
role: StaffRole;
}) => Promise<AuthUser>;
logout: () => void;
}
export type StaffRole = 'case_manager' | 'physician' | 'operator' | 'admin';
const AuthContext = createContext<AuthContextValue | null>(null);
const STAFF_ROLES = ['case_manager', 'physician', 'operator', 'admin'];
export function AuthProvider({ children }: { children: ReactNode }): JSX.Element {
const [session, setSession] = useState<Session | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
const existing = loadSession();
if (existing) {
setAuthToken(existing.token);
setSession(existing);
}
setReady(true);
}, []);
const persist = useCallback((next: Session) => {
setAuthToken(next.token);
saveSession(next);
setSession(next);
}, []);
const login = useCallback(
async (username: string, password: string) => {
const result = await api.login(username, password);
if (!STAFF_ROLES.includes(result.user.role)) {
throw new Error('该账号无医护端访问权限');
}
persist({ token: result.token, user: result.user });
return result.user;
},
[persist],
);
const register = useCallback(
async (input: { username: string; password: string; role: StaffRole }) => {
const result = await api.register(input);
persist({ token: result.token, user: result.user });
return result.user;
},
[persist],
);
const logout = useCallback(() => {
setAuthToken(null);
clearSession();
setSession(null);
}, []);
const value = useMemo<AuthContextValue>(
() => ({ user: session?.user ?? null, ready, login, register, logout }),
[session, ready, login, register, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// eslint-disable-next-line react-refresh/only-export-components
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
@@ -0,0 +1,25 @@
import type { AuthUser } from '../api/types';
const KEY = 'pcm.admin.session';
export interface Session {
token: string;
user: AuthUser;
}
export function loadSession(): Session | null {
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as Session) : null;
} catch {
return null;
}
}
export function saveSession(session: Session): void {
localStorage.setItem(KEY, JSON.stringify(session));
}
export function clearSession(): void {
localStorage.removeItem(KEY);
}
@@ -0,0 +1,120 @@
.layout {
min-height: 100vh;
}
/* 顶部导航栏(替代侧栏;2–3 个分区用 Tab 更轻) */
.topbar {
position: sticky;
top: 0;
z-index: 30;
height: var(--topbar-height);
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
box-shadow: var(--shadow-card);
display: flex;
align-items: stretch;
gap: var(--space-5);
padding: 0 var(--space-5);
}
.topbar__brand {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.topbar__logo {
width: 34px;
height: 34px;
border-radius: 9px;
background: var(--color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.topbar__title {
font-weight: 700;
font-size: var(--font-lg);
white-space: nowrap;
}
.topbar__tabs {
display: flex;
align-items: stretch;
gap: var(--space-2);
flex: 1;
}
.topbar__tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 var(--space-3);
font-size: var(--font-md);
font-weight: 600;
color: var(--color-text-soft);
border-bottom: 2px solid transparent;
}
.topbar__tab:hover {
color: var(--color-text);
}
.topbar__tab.is-active {
color: var(--color-primary-strong);
border-bottom-color: var(--color-primary);
}
.topbar__tab svg {
flex-shrink: 0;
}
.topbar__user {
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
}
.topbar__user-info {
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: 1.2;
}
.topbar__user-name {
font-weight: 600;
font-size: var(--font-sm);
}
.topbar__user-role {
font-size: var(--font-xs);
color: var(--color-text-soft);
}
.topbar__logout {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-strong);
color: var(--color-text-soft);
display: flex;
align-items: center;
justify-content: center;
}
.topbar__logout:hover {
color: var(--color-danger);
border-color: var(--color-danger);
}
.content {
width: 100%;
padding: var(--space-6);
}
@media (max-width: 720px) {
.topbar {
gap: var(--space-3);
padding: 0 var(--space-3);
}
.topbar__title {
display: none;
}
.topbar__user-info {
display: none;
}
}
@@ -0,0 +1,60 @@
import { NavLink, Outlet } from 'react-router-dom';
import { BookOpen, ClipboardList, FileSearch, LogOut, Stethoscope } from 'lucide-react';
import { useAuth } from '../auth/AuthContext';
import { roleLabel } from '../lib/format';
import { can } from '../lib/rbac';
import './Layout.css';
export function Layout(): JSX.Element {
const { user, logout } = useAuth();
const role = user?.role;
return (
<div className="layout">
<header className="topbar">
<div className="topbar__brand">
<span className="topbar__logo">
<Stethoscope size={22} strokeWidth={1.75} />
</span>
<span className="topbar__title">PCM </span>
</div>
<nav className="topbar__tabs">
{can(role, 'patient:read') && (
<NavLink to="/worklist" className={tabClass}>
<ClipboardList size={17} strokeWidth={1.75} />
</NavLink>
)}
{can(role, 'knowledge:write') && (
<NavLink to="/knowledge" className={tabClass}>
<BookOpen size={17} strokeWidth={1.75} />
</NavLink>
)}
{can(role, 'audit:read') && (
<NavLink to="/audit" className={tabClass}>
<FileSearch size={17} strokeWidth={1.75} />
</NavLink>
)}
</nav>
<div className="topbar__user">
<div className="topbar__user-info">
<span className="topbar__user-name">{user?.username}</span>
<span className="topbar__user-role">{user ? roleLabel(user.role) : ''}</span>
</div>
<button className="topbar__logout" onClick={logout} type="button" aria-label="退出登录">
<LogOut size={18} strokeWidth={1.75} />
</button>
</div>
</header>
<main className="content">
<Outlet />
</main>
</div>
);
}
function tabClass({ isActive }: { isActive: boolean }): string {
return `topbar__tab${isActive ? ' is-active' : ''}`;
}
@@ -0,0 +1,30 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react';
interface ToastContextValue {
show: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }): JSX.Element {
const [message, setMessage] = useState<string | null>(null);
const show = useCallback((msg: string) => {
setMessage(msg);
window.setTimeout(() => setMessage(null), 3000);
}, []);
return (
<ToastContext.Provider value={{ show }}>
{children}
{message && <div className="toast">{message}</div>}
</ToastContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}
@@ -0,0 +1,38 @@
import type { Alert } from '../../api/types';
import { formatTime, indicatorLabel, riskBadgeClass, riskLabel } from '../../lib/format';
import './workbench.css';
export function AlertsPanel({ alerts }: { alerts: Alert[] }): JSX.Element {
const open = alerts.filter((a) => a.status === 'open');
return (
<div className="card">
<div className="card-header">
<span></span>
{open.length > 0 && <span className="badge badge-danger">{open.length} </span>}
</div>
{alerts.length === 0 ? (
<p className="empty"></p>
) : (
<div className="alert-list">
{alerts.map((a) => (
<div key={a.id} className={`alert-item alert-item--${a.level}`}>
<div className="spread">
<span className={`badge ${riskBadgeClass(a.level)}`}>{riskLabel(a.level)}</span>
<span className="muted alert-item__time">{formatTime(a.createdAt)}</span>
</div>
<p className="alert-item__indicator">
{indicatorLabel(a.indicator)}<strong>{a.value}</strong>
</p>
{a.messages.map((m, i) => (
<p key={i} className="alert-item__msg">
{m}
</p>
))}
<p className="alert-item__trace muted">{a.ruleIds.join('、') || '—'}</p>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,197 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { api, ApiError } from '../../api/client';
import { useToast } from '../Toast';
import type { CarePlan, Intervention, InterventionKind, Role } from '../../api/types';
import { formatTime, interventionLabel } from '../../lib/format';
import './workbench.css';
interface Props {
caseId: string;
plans: CarePlan[];
role?: Role;
onCreated: () => void | Promise<void>;
}
const KINDS: InterventionKind[] = ['clinical', 'lifestyle', 'habit'];
const FREQ_OPTIONS = [
{ value: 'weekly', label: '每周' },
{ value: 'biweekly', label: '每两周' },
{ value: 'monthly', label: '每月' },
];
export function CarePlanPanel({ caseId, plans, role, onCreated }: Props): JSX.Element {
const { show } = useToast();
const canWrite = role === 'case_manager' || role === 'physician';
const [creating, setCreating] = useState(false);
const [goalsText, setGoalsText] = useState('');
const [frequency, setFrequency] = useState('weekly');
const [interventions, setInterventions] = useState<Intervention[]>([
{ kind: 'lifestyle', description: '' },
]);
const [busy, setBusy] = useState(false);
function updateIntervention(idx: number, patch: Partial<Intervention>): void {
setInterventions((prev) => prev.map((it, i) => (i === idx ? { ...it, ...patch } : it)));
}
function addIntervention(): void {
setInterventions((prev) => [...prev, { kind: 'lifestyle', description: '' }]);
}
function removeIntervention(idx: number): void {
setInterventions((prev) => prev.filter((_, i) => i !== idx));
}
async function submit(e: React.FormEvent): Promise<void> {
e.preventDefault();
const goals = goalsText
.split('\n')
.map((g) => g.trim())
.filter(Boolean);
if (goals.length === 0) {
show('请至少填写一个目标');
return;
}
const cleanInterventions = interventions.filter((it) => it.description.trim());
setBusy(true);
try {
await api.createCarePlan(caseId, {
goals,
interventions: cleanInterventions,
followUpFrequency: frequency,
});
show('照护计划已创建');
setGoalsText('');
setInterventions([{ kind: 'lifestyle', description: '' }]);
setCreating(false);
await onCreated();
} catch (err) {
show(err instanceof ApiError ? err.message : '创建失败');
} finally {
setBusy(false);
}
}
return (
<div className="card">
<div className="card-header">
<span></span>
{canWrite && !creating && (
<button className="btn btn-ghost btn-sm" onClick={() => setCreating(true)} type="button">
+
</button>
)}
</div>
{creating && (
<form className="careplan-form" onSubmit={submit}>
<div className="field">
<label></label>
<textarea
rows={3}
value={goalsText}
onChange={(e) => setGoalsText(e.target.value)}
placeholder={'如:空腹血糖控制在 5.1 mmol/L 以下\n规律产检'}
/>
</div>
<div className="field">
<label></label>
{interventions.map((it, i) => (
<div key={i} className="careplan-intervention">
<select
value={it.kind}
onChange={(e) => updateIntervention(i, { kind: e.target.value as InterventionKind })}
>
{KINDS.map((k) => (
<option key={k} value={k}>
{interventionLabel(k)}
</option>
))}
</select>
<input
value={it.description}
onChange={(e) => updateIntervention(i, { description: e.target.value })}
placeholder="干预描述,如:饮食控制 + 餐后散步"
/>
{interventions.length > 1 && (
<button
type="button"
className="careplan-remove"
onClick={() => removeIntervention(i)}
aria-label="移除"
>
<X size={16} strokeWidth={2} />
</button>
)}
</div>
))}
<button type="button" className="btn btn-ghost btn-sm" onClick={addIntervention}>
+
</button>
</div>
<div className="field">
<label>访</label>
<select value={frequency} onChange={(e) => setFrequency(e.target.value)}>
{FREQ_OPTIONS.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</select>
</div>
<div className="row">
<button className="btn btn-primary" type="submit" disabled={busy}>
{busy ? '保存中…' : '保存计划'}
</button>
<button
className="btn btn-ghost"
type="button"
onClick={() => setCreating(false)}
disabled={busy}
>
</button>
</div>
</form>
)}
{plans.length === 0 && !creating ? (
<p className="empty"></p>
) : (
<div className="careplan-list">
{plans.map((p) => (
<div key={p.id} className="careplan-item">
<div className="spread">
<span className="badge badge-ok">{freqLabel(p.followUpFrequency)}</span>
<span className="muted careplan-item__time">{formatTime(p.createdAt)}</span>
</div>
<p className="careplan-item__label"></p>
<ul>
{p.goals.map((g, i) => (
<li key={i}>{g}</li>
))}
</ul>
{p.interventions.length > 0 && (
<>
<p className="careplan-item__label"></p>
<ul>
{p.interventions.map((it, i) => (
<li key={i}>
<span className="badge badge-neutral">{interventionLabel(it.kind)}</span>{' '}
{it.description}
</li>
))}
</ul>
</>
)}
</div>
))}
</div>
)}
</div>
);
}
function freqLabel(value: string): string {
return FREQ_OPTIONS.find((f) => f.value === value)?.label ?? value;
}
@@ -0,0 +1,152 @@
import { useState } from 'react';
import { ArrowRight, Check } from 'lucide-react';
import { api, ApiError } from '../../api/client';
import { useToast } from '../Toast';
import { useAuth } from '../../auth/AuthContext';
import type { PregnancyCase, Role } from '../../api/types';
import { CASE_STAGES, formatTime, nextStages, stageExplainer, stageLabel } from '../../lib/format';
import './workbench.css';
interface Props {
caseInfo: PregnancyCase;
role?: Role;
onChanged: () => void | Promise<void>;
}
export function CaseFlowPanel({ caseInfo, role, onChanged }: Props): JSX.Element {
const { show } = useToast();
const { user } = useAuth();
const [busy, setBusy] = useState(false);
const canAdvance = role === 'case_manager' || role === 'physician';
const currentIdx = CASE_STAGES.indexOf(caseInfo.stage);
const targets = nextStages(caseInfo.stage);
async function advance(to: (typeof targets)[number]): Promise<void> {
setBusy(true);
try {
await api.advanceCase(caseInfo.id, to, `${stageLabel(to)}(工作台推进)`);
show(`已推进至「${stageLabel(to)}`);
await onChanged();
} catch (err) {
show(err instanceof ApiError ? err.message : '流转失败');
} finally {
setBusy(false);
}
}
async function claim(): Promise<void> {
if (!user) return;
setBusy(true);
try {
await api.assignManager(caseInfo.id, user.id);
show('已指派给我');
await onChanged();
} catch (err) {
show(err instanceof ApiError ? err.message : '指派失败');
} finally {
setBusy(false);
}
}
return (
<div className="card">
<div className="card-header">
<span></span>
<span className={`badge ${caseInfo.status === 'open' ? 'badge-ok' : 'badge-neutral'}`}>
{caseInfo.status === 'open' ? '进行中' : '已关闭'}
</span>
</div>
{/* 阶段进度 */}
<div className="stepper">
{CASE_STAGES.map((s, i) => (
<div
key={s}
className={`stepper__node${i < currentIdx ? ' is-done' : ''}${
i === currentIdx ? ' is-current' : ''
}`}
>
<span className="stepper__dot">
{i < currentIdx ? <Check size={13} strokeWidth={3} /> : i + 1}
</span>
<span className="stepper__label">{stageLabel(s)}</span>
</div>
))}
</div>
{/* 当前阶段说明 + 建议下一步 */}
<div className="flow-explainer">
<p className="flow-explainer__current">
<span className="badge badge-info">{stageLabel(caseInfo.stage)}</span>
<span>{stageExplainer(caseInfo.stage)}</span>
</p>
{targets.length > 0 && (
<p className="flow-explainer__next muted">
{targets.map((t) => stageLabel(t)).join(' 或 ')} {stageExplainer(targets[0])}
</p>
)}
</div>
{/* 指派 */}
<div className="spread flow-assign">
<span className="muted">
{caseInfo.caseManagerId
? caseInfo.caseManagerId === user?.id
? '我'
: caseInfo.caseManagerId.slice(0, 8)
: '未指派'}
</span>
{role === 'case_manager' && caseInfo.caseManagerId !== user?.id && (
<button className="btn btn-ghost btn-sm" onClick={claim} disabled={busy} type="button">
</button>
)}
</div>
{/* 流转操作 */}
{canAdvance ? (
targets.length > 0 ? (
<div className="flow-actions">
<span className="muted"></span>
{targets.map((t) => (
<button
key={t}
className="btn btn-calm btn-sm"
onClick={() => advance(t)}
disabled={busy || caseInfo.status === 'closed'}
type="button"
>
{stageLabel(t)}
</button>
))}
</div>
) : (
<p className="muted flow-actions"></p>
)
) : (
<p className="muted flow-actions"></p>
)}
{/* 历史 */}
{caseInfo.history.length > 0 && (
<div className="flow-history">
<p className="flow-history__title muted"></p>
{caseInfo.history
.slice()
.reverse()
.map((h, i) => (
<div key={i} className="flow-history__item">
<span className="badge badge-neutral">
{stageLabel(h.from)} <ArrowRight size={12} strokeWidth={2} /> {stageLabel(h.to)}
</span>
<span className="muted">{h.reason}</span>
<span className="muted flow-history__time">{formatTime(h.at)}</span>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,125 @@
import { Activity, AlertTriangle, ArrowRight, ClipboardList, History, Route } from 'lucide-react';
import type { Alert, CarePlan, CaseStage, Observation, PregnancyCase, Reminder } from '../../api/types';
import { riskBadgeClass, riskLabel, stageLabel } from '../../lib/format';
import './workbench.css';
interface Props {
caseInfo: PregnancyCase;
alerts: Alert[];
observations: Observation[];
carePlans: CarePlan[];
reminders: Reminder[];
}
/** 依据当前阶段 + 是否已有照护计划,给出“此刻该做什么”的一句话建议 */
function nextAction(stage: CaseStage, hasCarePlan: boolean): string {
switch (stage) {
case 'screening':
return '完善基线信息与首批观测后,将个案推进到「评估」。';
case 'assessment':
return '结合左侧预警与观测综合评估母婴状况,确认后推进到「风险分层」。';
case 'risk_stratification':
return '依据评估确定风险等级,随后推进到「计划制定」并在右侧制定照护计划。';
case 'planning':
return hasCarePlan
? '照护计划已制定,可推进到「实施协调」。'
: '请在右侧「处置与计划」新建照护计划,再推进到「实施协调」。';
case 'implementation':
return '执行照护计划、按需下发提醒,随后推进到「监测随访」。';
case 'monitoring':
return '持续监测随访并关注新预警;达到复评条件后推进到「评价」。';
case 'evaluation':
return '评估干预效果:达标可「转出」,需调整则退回「评估」。';
case 'transition':
return '个案已进入转出 / 产后随访,管理闭环完成。';
default:
return '';
}
}
const STEPS = [
{ icon: Route, num: 1, title: '看阶段', desc: '「个案流程」标明当前所处环节' },
{ icon: Activity, num: 2, title: '看数据', desc: '左侧「监测与分析」预警与观测' },
{ icon: ClipboardList, num: 3, title: '做处置', desc: '右侧「处置与计划」建议·计划·提醒' },
{ icon: History, num: 4, title: '查留痕', desc: '底部「时间线」按时间回溯全过程' },
] as const;
export function CaseGuidePanel({
caseInfo,
alerts,
observations,
carePlans,
reminders,
}: Props): JSX.Element {
const openAlerts = alerts.filter((a) => a.status === 'open').length;
const hasCarePlan = carePlans.length > 0;
const action = nextAction(caseInfo.stage, hasCarePlan);
return (
<div className="card guide-card">
<div className="card-header">
<span> · </span>
<span className={`badge ${riskBadgeClass(caseInfo.riskLevel)}`}>
{riskLabel(caseInfo.riskLevel)}
</span>
</div>
<p className="guide-intro muted">
<strong>1 </strong>
</p>
<ol className="guide-map">
{STEPS.map((s) => {
const Icon = s.icon;
return (
<li key={s.num} className="guide-map__step">
<span className="guide-map__icon">
<Icon size={16} strokeWidth={1.75} />
</span>
<div className="guide-map__body">
<p className="guide-map__title">
<span className="guide-map__num">{s.num}</span>
{s.title}
</p>
<p className="guide-map__desc muted">{s.desc}</p>
</div>
</li>
);
})}
</ol>
<div className="guide-stats">
<span className={`guide-stat${openAlerts > 0 ? ' is-alert' : ''}`}>
<span className="guide-stat__num">{openAlerts}</span>
</span>
<span className="guide-stat">
<span className="guide-stat__num">{observations.length}</span>
</span>
<span className="guide-stat">
<span className="guide-stat__num">{carePlans.length}</span>
</span>
<span className="guide-stat">
<span className="guide-stat__num">{reminders.length}</span>
</span>
<span className="guide-stat">
<strong>{stageLabel(caseInfo.stage)}</strong>
</span>
</div>
{openAlerts > 0 && (
<p className="guide-warn">
<AlertTriangle size={15} strokeWidth={2} /> {openAlerts}
</p>
)}
<p className="guide-next">
<ArrowRight size={16} strokeWidth={2} />
<span>
<strong></strong>
{action}
</span>
</p>
</div>
);
}
@@ -0,0 +1,250 @@
import { useMemo, useState } from 'react';
import {
Activity,
AlertTriangle,
Bell,
ClipboardList,
FolderPlus,
Workflow,
HeartHandshake,
Smile,
} from 'lucide-react';
import type {
Alert,
CarePlan,
Observation,
PregnancyCase,
Reminder,
Disposition,
Referral,
EmotionCheckin,
} from '../../api/types';
import {
formatTime,
indicatorLabel,
reminderLabel,
riskLabel,
stageLabel,
} from '../../lib/format';
import './workbench.css';
type EventKind = 'observation' | 'alert' | 'stage' | 'plan' | 'reminder' | 'case' | 'disposition' | 'referral' | 'emotion';
type EventTone = 'data' | 'warn' | 'danger' | 'process' | 'care' | 'info';
interface TimelineEvent {
id: string;
at: string;
kind: EventKind;
tone: EventTone;
title: string;
detail?: string;
ga?: number;
}
const KIND_META: Record<EventKind, { icon: typeof Bell; label: string }> = {
observation: { icon: Activity, label: '数据采集' },
alert: { icon: AlertTriangle, label: '预警' },
stage: { icon: Workflow, label: '流程流转' },
plan: { icon: ClipboardList, label: '照护计划' },
reminder: { icon: Bell, label: '提醒下发' },
case: { icon: FolderPlus, label: '个案' },
disposition: { icon: ClipboardList, label: '处置下发' },
referral: { icon: HeartHandshake, label: '跨学科会诊' },
emotion: { icon: Smile, label: '身心自评' },
};
interface Props {
caseInfo: PregnancyCase;
observations: Observation[];
alerts: Alert[];
carePlans: CarePlan[];
reminders: Reminder[];
dispositions?: Disposition[];
referrals?: Referral[];
emotions?: EmotionCheckin[];
}
function buildEvents(props: Props): TimelineEvent[] {
const { caseInfo, observations, alerts, carePlans, reminders, dispositions, referrals, emotions } = props;
const events: TimelineEvent[] = [];
// 开案
events.push({
id: `case-${caseInfo.id}`,
at: caseInfo.createdAt,
kind: 'case',
tone: 'process',
title: '建立个案',
detail: `初始风险分层:${riskLabel(caseInfo.riskLevel)}`,
});
// 流程流转
for (let i = 0; i < caseInfo.history.length; i += 1) {
const h = caseInfo.history[i];
events.push({
id: `stage-${i}-${h.at}`,
at: h.at,
kind: 'stage',
tone: 'process',
title: `${stageLabel(h.from)}${stageLabel(h.to)}`,
detail: h.reason,
});
}
// 数据采集
for (const o of observations) {
events.push({
id: `obs-${o.id}`,
at: o.measuredAt,
kind: 'observation',
tone: 'data',
title: `${indicatorLabel(o.indicator)} ${o.value} ${o.unit}`,
detail: o.qcStatus === 'rejected' ? `质控存疑:${o.qcFlags.join('、') || '—'}` : undefined,
ga: o.gestationalWeeks,
});
}
// 预警
for (const a of alerts) {
events.push({
id: `alert-${a.id}`,
at: a.createdAt,
kind: 'alert',
tone: a.level === 'high' ? 'danger' : a.level === 'medium' ? 'warn' : 'info',
title: `${riskLabel(a.level)}预警 · ${indicatorLabel(a.indicator)} ${a.value}`,
detail: a.messages.join('') || undefined,
});
}
// 照护计划
for (const p of carePlans) {
events.push({
id: `plan-${p.id}`,
at: p.createdAt,
kind: 'plan',
tone: 'care',
title: '制定照护计划',
detail: `目标:${p.goals.join('、') || '—'};随访频率:${p.followUpFrequency}`,
});
}
// 提醒下发
for (const r of reminders) {
events.push({
id: `rem-${r.id}`,
at: r.scheduledAt,
kind: 'reminder',
tone: 'info',
title: `提醒:${reminderLabel(r.effectiveType)}`,
detail: r.message,
});
}
// 处置单 (T-D.10)
for (const d of dispositions ?? []) {
events.push({
id: `disp-${d.id}`,
at: d.createdAt,
kind: 'disposition',
tone: d.status === 'closed' ? 'info' : 'warn',
title: `处置下发:${d.title}`,
detail: `状态: ${d.status === 'closed' ? '已闭环' : '执行中'} · 动作明细: ${d.actions.map((a) => a.description).join('、')}`,
});
}
// 转会诊 (T-D.10)
for (const r of referrals ?? []) {
events.push({
id: `ref-${r.id}`,
at: r.createdAt,
kind: 'referral',
tone: r.urgency === 'emergency' ? 'danger' : 'care',
title: `院内协同:发起 ${r.type === 'referral' ? '转诊' : '多科会诊'}`,
detail: `状态: ${r.status === 'completed' ? '会诊已回复并闭环' : '进行中'} · 简述: ${r.clinicalSummary}${r.doctorReply ? ` · 专家意见: ${r.doctorReply}` : ''}`,
});
}
// 情绪自评打卡 (T-D.10)
for (const e of emotions ?? []) {
events.push({
id: `emo-${e.id}`,
at: e.createdAt,
kind: 'emotion',
tone: e.status === 'crisis' ? 'danger' : e.status === 'concerning' ? 'warn' : 'data',
title: `孕妇自评打卡:今日情绪分值 ${e.score}`,
detail: `状态: ${e.status === 'crisis' ? '触发重度身心危机信号' : e.status === 'concerning' ? '出现明显焦虑情绪' : '平稳'} · 打卡感言: ${e.note}`,
});
}
// 时间倒序(最新在上)
return events.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
}
const FILTERS: { key: EventKind | 'all'; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'observation', label: '数据' },
{ key: 'alert', label: '预警' },
{ key: 'stage', label: '流转' },
{ key: 'plan', label: '计划' },
{ key: 'reminder', label: '提醒' },
{ key: 'disposition', label: '处置' },
{ key: 'referral', label: '会诊' },
{ key: 'emotion', label: '情绪' },
];
export function CaseTimeline(props: Props): JSX.Element {
const [filter, setFilter] = useState<EventKind | 'all'>('all');
const events = useMemo(() => buildEvents(props), [props]);
const filtered = filter === 'all' ? events : events.filter((e) => e.kind === filter);
return (
<div className="card">
<div className="card-header">
<span>线</span>
<span className="muted">{events.length} </span>
</div>
<p className="muted timeline-hint">
</p>
<div className="timeline-filters">
{FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={`timeline-filter${filter === f.key ? ' is-active' : ''}`}
onClick={() => setFilter(f.key)}
>
{f.label}
</button>
))}
</div>
{filtered.length === 0 ? (
<p className="empty"></p>
) : (
<ol className="timeline">
{filtered.map((e) => {
const Icon = KIND_META[e.kind].icon;
return (
<li key={e.id} className={`timeline-item timeline-item--${e.tone}`}>
<span className="timeline-item__dot">
<Icon size={14} strokeWidth={1.75} />
</span>
<div className="timeline-item__content">
<div className="timeline-item__head">
<span className="timeline-item__kind">{KIND_META[e.kind].label}</span>
{e.ga != null && <span className="timeline-item__ga">{e.ga}</span>}
<span className="timeline-item__time muted">{formatTime(e.at)}</span>
</div>
<p className="timeline-item__title">{e.title}</p>
{e.detail && <p className="timeline-item__detail muted">{e.detail}</p>}
</div>
</li>
);
})}
</ol>
)}
</div>
);
}
@@ -0,0 +1,64 @@
import { useState } from 'react';
import type { Observation } from '../../api/types';
import { formatTime, indicatorLabel } from '../../lib/format';
import './workbench.css';
export function ObservationsPanel({ observations }: { observations: Observation[] }): JSX.Element {
const [expanded, setExpanded] = useState(false);
const shown = expanded ? observations : observations.slice(0, 6);
return (
<div className="card">
<div className="card-header">
<span></span>
<span className="muted">{observations.length} </span>
</div>
{observations.length === 0 ? (
<p className="empty"></p>
) : (
<>
<table className="table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{shown.map((o) => (
<tr key={o.id}>
<td>{indicatorLabel(o.indicator)}</td>
<td style={{ fontWeight: 600 }}>
{o.value} {o.unit}
</td>
<td>{o.gestationalWeeks}</td>
<td className="muted">{formatTime(o.measuredAt)}</td>
<td>
{o.qcStatus === 'accepted' ? (
<span className="badge badge-ok"></span>
) : (
<span className="badge badge-warn"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
{observations.length > 6 && (
<button
className="btn btn-ghost btn-sm"
style={{ marginTop: 'var(--space-3)' }}
onClick={() => setExpanded((v) => !v)}
type="button"
>
{expanded ? '收起' : `展开全部 ${observations.length}`}
</button>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,107 @@
import { useState } from 'react';
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
import { api, ApiError } from '../../api/client';
import { useToast } from '../Toast';
import { useAuth } from '../../auth/AuthContext';
import type { PregnancyCase, Recommendation } from '../../api/types';
import { formatTime } from '../../lib/format';
import './workbench.css';
interface Props {
patientId: string;
caseInfo: PregnancyCase;
}
interface Confirmation {
by: string;
at: string;
}
export function RecommendationPanel({ patientId, caseInfo }: Props): JSX.Element {
const { show } = useToast();
const { user } = useAuth();
const [rec, setRec] = useState<Recommendation | null>(null);
const [loading, setLoading] = useState(false);
const [confirmation, setConfirmation] = useState<Confirmation | null>(null);
async function generate(): Promise<void> {
setLoading(true);
setConfirmation(null);
try {
const r = await api.getRecommendation(patientId);
setRec(r);
} catch (err) {
show(err instanceof ApiError ? err.message : '生成建议失败');
} finally {
setLoading(false);
}
}
function confirm(): void {
setConfirmation({ by: user?.username ?? '当前用户', at: new Date().toISOString() });
show('已人工确认采纳该建议');
}
const needsConfirm = rec?.requiresHumanConfirmation ?? false;
return (
<div className="card">
<div className="card-header">
<span>AI </span>
<button className="btn btn-ghost btn-sm" onClick={generate} disabled={loading} type="button">
{loading ? '生成中…' : rec ? '重新生成' : '生成建议'}
</button>
</div>
{!rec ? (
<p className="empty"></p>
) : (
<>
{needsConfirm && (
<div className="rec-gate">
<strong>
<AlertTriangle size={15} strokeWidth={2} />
</strong>
<p>
{caseInfo.riskLevel === 'high' ? '高风险' : '中风险'}
AI <strong></strong>REQ-10.3
</p>
</div>
)}
<p className="rec-section-title"></p>
<ul className="rec-actions">
{rec.actions.map((a, i) => (
<li key={i}>{a}</li>
))}
</ul>
<p className="rec-section-title"></p>
<ul className="rec-rationale">
{rec.rationale.map((r, i) => (
<li key={i} className="muted">
{r}
</li>
))}
</ul>
{needsConfirm ? (
confirmation ? (
<div className="rec-confirmed">
<CheckCircle2 size={15} strokeWidth={2} /> <strong>{confirmation.by}</strong> ·{' '}
{formatTime(confirmation.at)}
</div>
) : (
<button className="btn btn-primary btn-block" onClick={confirm} type="button">
</button>
)
) : (
<div className="rec-auto muted">怀</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,161 @@
import { useState } from 'react';
import { AlertTriangle, ShieldCheck } from 'lucide-react';
import { api, ApiError } from '../../api/client';
import { useToast } from '../Toast';
import type { RedFlagResult, Role, Symptom } from '../../api/types';
import './workbench.css';
interface Props {
patientId: string;
role?: Role;
onTriggered: () => void | Promise<void>;
}
const SYMPTOMS: { value: Symptom; label: string }[] = [
{ value: 'severe_headache', label: '剧烈头痛' },
{ value: 'visual_disturbance', label: '视物模糊' },
{ value: 'epigastric_pain', label: '上腹痛' },
{ value: 'reduced_fetal_movement', label: '胎动减少/消失' },
{ value: 'vaginal_bleeding', label: '阴道出血' },
{ value: 'severe_edema', label: '严重水肿' },
];
export function RedflagPanel({ patientId, role, onTriggered }: Props): JSX.Element {
const { show } = useToast();
const canCheck = role === 'case_manager';
const [systolic, setSystolic] = useState('');
const [diastolic, setDiastolic] = useState('');
const [glucose, setGlucose] = useState('');
const [symptoms, setSymptoms] = useState<Set<Symptom>>(new Set());
const [result, setResult] = useState<RedFlagResult | null>(null);
const [busy, setBusy] = useState(false);
function toggle(s: Symptom): void {
setSymptoms((prev) => {
const next = new Set(prev);
if (next.has(s)) next.delete(s);
else next.add(s);
return next;
});
}
async function check(): Promise<void> {
setBusy(true);
try {
const res = await api.redflagCheck(patientId, {
systolicBp: systolic ? Number(systolic) : undefined,
diastolicBp: diastolic ? Number(diastolic) : undefined,
fastingGlucose: glucose ? Number(glucose) : undefined,
symptoms: [...symptoms],
});
setResult(res);
if (res.triggered) {
show('已触发红旗急症:已通知并升级个案');
await onTriggered();
} else {
show('未触发红旗急症');
}
} catch (err) {
show(err instanceof ApiError ? err.message : '检查失败');
} finally {
setBusy(false);
}
}
if (!canCheck) {
return (
<div className="card">
<div className="card-header"></div>
<p className="empty"></p>
</div>
);
}
return (
<div className="card">
<div className="card-header"></div>
<p className="muted" style={{ marginBottom: 'var(--space-4)' }}>
/
</p>
<div className="grid-2">
<div className="field">
<label> (mmHg)</label>
<input
type="number"
inputMode="numeric"
value={systolic}
onChange={(e) => setSystolic(e.target.value)}
placeholder="如 165"
/>
</div>
<div className="field">
<label> (mmHg)</label>
<input
type="number"
inputMode="numeric"
value={diastolic}
onChange={(e) => setDiastolic(e.target.value)}
placeholder="如 110"
/>
</div>
</div>
<div className="field">
<label> (mmol/L)</label>
<input
type="number"
inputMode="decimal"
step="0.1"
value={glucose}
onChange={(e) => setGlucose(e.target.value)}
/>
</div>
<div className="field">
<label></label>
<div className="redflag-symptoms">
{SYMPTOMS.map((s) => (
<label key={s.value} className={`redflag-chip${symptoms.has(s.value) ? ' is-on' : ''}`}>
<input
type="checkbox"
checked={symptoms.has(s.value)}
onChange={() => toggle(s.value)}
/>
{s.label}
</label>
))}
</div>
</div>
<button className="btn btn-primary" onClick={check} disabled={busy} type="button">
{busy ? '检查中…' : '执行红旗检查'}
</button>
{result && (
<div className={`redflag-result${result.triggered ? ' is-danger' : ' is-ok'}`}>
{result.triggered ? (
<>
<strong>
<AlertTriangle size={15} strokeWidth={2} />
</strong>
{result.hits.map((h) => (
<div key={h.ruleId} className="redflag-hit">
<span className="badge badge-danger">{h.ruleId}</span>
<span>
{h.message} {h.advice}
</span>
</div>
))}
{result.patientAdvice && <p className="redflag-advice">{result.patientAdvice}</p>}
</>
) : (
<span className="redflag-clear">
<ShieldCheck size={15} strokeWidth={2} />
</span>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,78 @@
import { useState } from 'react';
import {
Activity,
Armchair,
Bell,
CalendarCheck,
Droplet,
Footprints,
Pill,
} from 'lucide-react';
import type { ReminderType, Reminder } from '../../api/types';
import { formatTime, reminderLabel } from '../../lib/format';
import './workbench.css';
const ICONS: Record<ReminderType, typeof Bell> = {
exercise: Footprints,
rest: Armchair,
water: Droplet,
medication: Pill,
checkup: CalendarCheck,
measurement: Activity,
};
function ReminderIcon({ type }: { type: ReminderType }): JSX.Element {
const Icon = ICONS[type] ?? Bell;
return <Icon size={16} strokeWidth={1.75} />;
}
export function RemindersPanel({ reminders }: { reminders: Reminder[] }): JSX.Element {
const [expanded, setExpanded] = useState(false);
const shown = expanded ? reminders : reminders.slice(0, 6);
return (
<div className="card">
<div className="card-header">
<span>访</span>
<span className="muted">{reminders.length} </span>
</div>
{reminders.length === 0 ? (
<p className="empty"></p>
) : (
<>
<div className="reminder-list">
{shown.map((r) => (
<div key={r.id} className="reminder-item">
<span className="reminder-item__icon">
<ReminderIcon type={r.effectiveType} />
</span>
<div className="reminder-item__body">
<div className="reminder-item__head">
<span className="reminder-item__type">{reminderLabel(r.effectiveType)}</span>
{r.adjustedForRisk && (
<span className="badge badge-warn" title={`原计划:${reminderLabel(r.type)}`}>
</span>
)}
<span className="muted reminder-item__time">{formatTime(r.scheduledAt)}</span>
</div>
<p className="reminder-item__msg">{r.message}</p>
</div>
</div>
))}
</div>
{reminders.length > 6 && (
<button
className="btn btn-ghost btn-sm"
style={{ marginTop: 'var(--space-3)' }}
onClick={() => setExpanded((v) => !v)}
type="button"
>
{expanded ? '收起' : `展开全部 ${reminders.length}`}
</button>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,617 @@
/* ===== 个案流程 stepper ===== */
.stepper {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.stepper__node {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px 4px 4px;
border-radius: var(--radius-pill);
background: var(--color-bg);
font-size: var(--font-xs);
color: var(--color-text-soft);
}
.stepper__node.is-done {
background: var(--color-ok-soft);
color: var(--color-ok);
}
.stepper__node.is-current {
background: var(--color-primary);
color: #fff;
font-weight: 700;
}
.stepper__dot {
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
}
.stepper__node.is-current .stepper__dot {
background: rgba(255, 255, 255, 0.3);
}
.flow-assign {
padding: var(--space-3) 0;
border-top: 1px solid var(--color-border);
font-size: var(--font-sm);
}
.flow-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
.flow-history {
margin-top: var(--space-4);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
.flow-history__title {
font-size: var(--font-sm);
margin-bottom: var(--space-2);
}
.flow-history__item {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-sm);
padding: 4px 0;
flex-wrap: wrap;
}
.flow-history__time {
margin-left: auto;
font-size: var(--font-xs);
}
/* ===== 预警 ===== */
.alert-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.alert-item {
border: 1px solid var(--color-border);
border-left: 4px solid var(--color-border-strong);
border-radius: var(--radius-md);
padding: var(--space-3);
}
.alert-item--medium {
border-left-color: var(--color-warn);
}
.alert-item--high {
border-left-color: var(--color-danger);
}
.alert-item__time {
font-size: var(--font-xs);
}
.alert-item__indicator {
margin: 6px 0 2px;
font-weight: 600;
}
.alert-item__msg {
font-size: var(--font-sm);
}
.alert-item__trace {
font-size: var(--font-xs);
margin-top: 4px;
}
/* ===== AI 建议 ===== */
.rec-gate {
background: var(--color-warn-soft);
border: 1px solid var(--color-warn);
border-radius: var(--radius-md);
padding: var(--space-3);
margin-bottom: var(--space-4);
font-size: var(--font-sm);
color: var(--color-text);
}
.rec-gate strong {
color: var(--color-warn);
}
.rec-section-title {
font-size: var(--font-sm);
font-weight: 700;
color: var(--color-text-soft);
margin: var(--space-3) 0 var(--space-2);
}
.rec-actions,
.rec-rationale {
padding-left: 20px;
font-size: var(--font-md);
}
.rec-actions li {
margin-bottom: 4px;
}
.rec-rationale li {
font-size: var(--font-sm);
margin-bottom: 2px;
}
.rec-confirmed {
margin-top: var(--space-4);
padding: var(--space-3);
background: var(--color-ok-soft);
color: var(--color-ok);
border-radius: var(--radius-md);
font-size: var(--font-sm);
}
.rec-auto {
margin-top: var(--space-3);
font-size: var(--font-sm);
}
/* ===== 照护计划 ===== */
.careplan-form {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--space-4);
margin-bottom: var(--space-4);
}
.careplan-intervention {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.careplan-intervention select {
width: 110px;
flex-shrink: 0;
}
.careplan-remove {
width: 32px;
flex-shrink: 0;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-md);
color: var(--color-text-soft);
font-size: 18px;
}
.careplan-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.careplan-item {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
}
.careplan-item__time {
font-size: var(--font-xs);
}
.careplan-item__label {
font-size: var(--font-xs);
font-weight: 700;
color: var(--color-text-soft);
margin: var(--space-2) 0 4px;
}
.careplan-item ul {
padding-left: 18px;
font-size: var(--font-sm);
}
/* ===== 红旗急症 ===== */
.redflag-symptoms {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.redflag-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: var(--radius-pill);
border: 1px solid var(--color-border-strong);
font-size: var(--font-sm);
cursor: pointer;
}
.redflag-chip.is-on {
background: var(--color-danger-soft);
border-color: var(--color-danger);
color: var(--color-danger);
}
.redflag-chip input {
display: none;
}
.redflag-result {
margin-top: var(--space-4);
padding: var(--space-3);
border-radius: var(--radius-md);
font-size: var(--font-sm);
}
.redflag-result.is-ok {
background: var(--color-ok-soft);
color: var(--color-ok);
}
.redflag-result.is-danger {
background: var(--color-danger-soft);
color: var(--color-text);
}
.redflag-result.is-danger strong {
color: var(--color-danger);
display: block;
margin-bottom: var(--space-2);
}
.redflag-hit {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: 4px;
}
.redflag-advice {
margin-top: var(--space-2);
font-style: italic;
}
/* SVG 图标对齐 */
.rec-gate strong,
.rec-confirmed,
.redflag-result strong,
.redflag-clear {
display: inline-flex;
align-items: center;
gap: 6px;
}
.rec-confirmed {
display: flex;
}
.careplan-remove {
display: flex;
align-items: center;
justify-content: center;
}
.flow-history__item .badge svg,
.stepper__dot svg {
flex-shrink: 0;
}
/* ===== 阶段说明(流程语义) ===== */
.flow-explainer {
margin-bottom: var(--space-3);
padding: var(--space-3);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.flow-explainer__current {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
font-size: var(--font-sm);
color: var(--color-text);
}
.flow-explainer__current .badge {
flex-shrink: 0;
}
.flow-explainer__next {
margin-top: var(--space-2);
font-size: var(--font-xs);
line-height: 1.5;
}
/* ===== 提醒与随访 ===== */
.reminder-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.reminder-item {
display: flex;
gap: var(--space-3);
align-items: flex-start;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.reminder-item__icon {
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: 50%;
background: var(--color-calm-soft);
color: var(--color-calm);
display: flex;
align-items: center;
justify-content: center;
}
.reminder-item__body {
flex: 1;
min-width: 0;
}
.reminder-item__head {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.reminder-item__type {
font-weight: 600;
font-size: var(--font-sm);
}
.reminder-item__time {
margin-left: auto;
font-size: var(--font-xs);
}
.reminder-item__msg {
margin-top: 2px;
font-size: var(--font-sm);
color: var(--color-text-soft);
}
/* ===== 个案动态时间线 ===== */
.timeline-hint {
font-size: var(--font-sm);
margin-bottom: var(--space-3);
line-height: 1.5;
}
.timeline-filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.timeline-filter {
padding: 4px 12px;
border-radius: var(--radius-pill);
border: 1px solid var(--color-border-strong);
font-size: var(--font-xs);
color: var(--color-text-soft);
background: var(--color-surface);
}
.timeline-filter.is-active {
background: var(--color-calm);
border-color: var(--color-calm);
color: #fff;
font-weight: 700;
}
.timeline {
list-style: none;
position: relative;
padding-left: 28px;
}
.timeline::before {
content: '';
position: absolute;
left: 11px;
top: 6px;
bottom: 6px;
width: 2px;
background: var(--color-border);
}
.timeline-item {
position: relative;
padding: 0 0 var(--space-4) 0;
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-item__dot {
position: absolute;
left: -28px;
top: 0;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg);
color: var(--color-text-soft);
border: 2px solid var(--color-border-strong);
}
.timeline-item--data .timeline-item__dot {
background: var(--color-calm-soft);
color: var(--color-calm);
border-color: var(--color-calm);
}
.timeline-item--warn .timeline-item__dot {
background: var(--color-warn-soft);
color: var(--color-warn);
border-color: var(--color-warn);
}
.timeline-item--danger .timeline-item__dot {
background: var(--color-danger-soft);
color: var(--color-danger);
border-color: var(--color-danger);
}
.timeline-item--process .timeline-item__dot {
background: var(--color-primary-soft);
color: var(--color-primary-strong);
border-color: var(--color-primary);
}
.timeline-item--care .timeline-item__dot {
background: var(--color-ok-soft);
color: var(--color-ok);
border-color: var(--color-ok);
}
.timeline-item--info .timeline-item__dot {
background: var(--color-calm-soft);
color: var(--color-calm);
border-color: var(--color-calm);
}
.timeline-item__content {
padding-top: 1px;
}
.timeline-item__head {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.timeline-item__kind {
font-size: var(--font-xs);
font-weight: 700;
color: var(--color-text-soft);
}
.timeline-item__ga {
font-size: var(--font-xs);
color: var(--color-calm);
background: var(--color-calm-soft);
padding: 1px 8px;
border-radius: var(--radius-pill);
}
.timeline-item__time {
font-size: var(--font-xs);
margin-left: auto;
}
.timeline-item__title {
font-size: var(--font-sm);
font-weight: 600;
margin-top: 2px;
}
.timeline-item__detail {
font-size: var(--font-xs);
margin-top: 2px;
line-height: 1.5;
}
.reminder-item__icon svg,
.timeline-item__dot svg {
flex-shrink: 0;
}
/* ===== 处置指引(本页怎么用) ===== */
.guide-card {
border-top: 3px solid var(--color-calm);
}
.guide-intro {
font-size: var(--font-sm);
line-height: 1.6;
margin-bottom: var(--space-4);
}
.guide-intro strong {
color: var(--color-text);
}
.guide-map {
list-style: none;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.guide-map__step {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-3);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.guide-map__icon {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 50%;
background: var(--color-calm-soft);
color: var(--color-calm);
display: flex;
align-items: center;
justify-content: center;
}
.guide-map__body {
min-width: 0;
}
.guide-map__title {
font-size: var(--font-sm);
font-weight: 700;
display: flex;
align-items: center;
gap: 6px;
}
.guide-map__num {
width: 18px;
height: 18px;
flex-shrink: 0;
border-radius: 50%;
background: var(--color-calm);
color: #fff;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
}
.guide-map__desc {
font-size: var(--font-xs);
line-height: 1.5;
margin-top: 2px;
}
.guide-stats {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
.guide-stat {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 12px;
border-radius: var(--radius-pill);
background: var(--color-bg);
font-size: var(--font-xs);
color: var(--color-text-soft);
}
.guide-stat__num {
font-weight: 700;
font-size: var(--font-md);
color: var(--color-text);
}
.guide-stat.is-alert {
background: var(--color-danger-soft);
color: var(--color-danger);
}
.guide-stat.is-alert .guide-stat__num {
color: var(--color-danger);
}
.guide-warn {
display: flex;
align-items: center;
gap: 6px;
margin-top: var(--space-3);
padding: var(--space-2) var(--space-3);
background: var(--color-warn-soft);
color: var(--color-warn);
border-radius: var(--radius-md);
font-size: var(--font-sm);
font-weight: 600;
}
.guide-next {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: var(--space-3);
padding: var(--space-3);
background: var(--color-calm-soft);
border-radius: var(--radius-md);
font-size: var(--font-sm);
line-height: 1.6;
}
.guide-next svg {
color: var(--color-calm);
flex-shrink: 0;
margin-top: 2px;
}
.guide-next strong {
color: var(--color-calm);
}
.guide-warn svg,
.guide-map__icon svg {
flex-shrink: 0;
}
@media (max-width: 720px) {
.guide-map {
grid-template-columns: repeat(2, 1fr);
}
}
+131
View File
@@ -0,0 +1,131 @@
import type { CaseStage, InterventionKind, ReminderType, RiskLevel, Role } from '../api/types';
export function riskLabel(level: RiskLevel): string {
return { low: '低风险', medium: '中风险', high: '高风险' }[level];
}
export function riskBadgeClass(level: RiskLevel): string {
return { low: 'badge-ok', medium: 'badge-warn', high: 'badge-danger' }[level];
}
export const CASE_STAGES: CaseStage[] = [
'screening',
'assessment',
'risk_stratification',
'planning',
'implementation',
'monitoring',
'evaluation',
'transition',
];
const STAGE_LABELS: Record<CaseStage, string> = {
screening: '筛查',
assessment: '评估',
risk_stratification: '风险分层',
planning: '计划制定',
implementation: '实施协调',
monitoring: '监测随访',
evaluation: '评价',
transition: '转出',
};
export function stageLabel(stage: CaseStage): string {
return STAGE_LABELS[stage] ?? stage;
}
/** 各阶段含义说明(管理流程语义,帮助医护理解“现在做什么”) */
const STAGE_EXPLAINERS: Record<CaseStage, string> = {
screening: '收集基线信息与既往史,初步识别高危因素。',
assessment: '结合观测数据综合评估母婴状况,明确需关注的问题。',
risk_stratification: '依据评估结果确定风险等级,决定管理强度与频次。',
planning: '制定个体化照护计划:设定目标与干预措施。',
implementation: '执行照护计划、协调资源、下发健康指导与提醒。',
monitoring: '持续监测指标与症状,按计划随访并捕捉预警。',
evaluation: '评估干预效果,判断是否达标或需调整计划。',
transition: '个案转出或进入产后随访,闭环结案。',
};
export function stageExplainer(stage: CaseStage): string {
return STAGE_EXPLAINERS[stage] ?? '';
}
/** 各阶段允许流转到的下一阶段(与后端 case-state-machine.ts 对齐) */
const TRANSITIONS: Record<CaseStage, CaseStage[]> = {
screening: ['assessment'],
assessment: ['risk_stratification'],
risk_stratification: ['planning'],
planning: ['implementation'],
implementation: ['monitoring'],
monitoring: ['evaluation', 'assessment'],
evaluation: ['transition', 'assessment'],
transition: [],
};
export function nextStages(from: CaseStage): CaseStage[] {
return TRANSITIONS[from] ?? [];
}
const ROLE_LABELS: Record<Role, string> = {
patient: '孕妇',
family: '家属',
case_manager: '个案管理师',
physician: '医生',
operator: '运营',
admin: '管理员',
};
export function roleLabel(role: Role): string {
return ROLE_LABELS[role] ?? role;
}
const INTERVENTION_LABELS: Record<InterventionKind, string> = {
clinical: '临床',
lifestyle: '生活方式',
habit: '习惯养成',
};
export function interventionLabel(kind: InterventionKind): string {
return INTERVENTION_LABELS[kind] ?? kind;
}
const INDICATOR_LABELS: Record<string, string> = {
fasting_glucose: '空腹血糖',
postprandial_glucose: '餐后血糖',
ogtt_1h: 'OGTT 1h',
ogtt_2h: 'OGTT 2h',
systolic_bp: '收缩压',
diastolic_bp: '舒张压',
weight: '体重',
heart_rate: '心率',
};
export function indicatorLabel(indicator: string): string {
return INDICATOR_LABELS[indicator] ?? indicator;
}
const REMINDER_LABELS: Record<ReminderType, string> = {
exercise: '运动',
rest: '休息',
water: '喝水',
medication: '服药',
checkup: '产检',
measurement: '监测打卡',
};
export function reminderLabel(type: ReminderType): string {
return REMINDER_LABELS[type] ?? type;
}
export function formatTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number): string => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(
d.getMinutes(),
)}`;
}
export function formatDate(iso: string): string {
return iso?.slice(0, 10) ?? '';
}
+44
View File
@@ -0,0 +1,44 @@
// 与 backend/src/modules/auth/rbac.ts 对齐的能力矩阵(UI 门控用,后端为最终授权边界)。
import type { Role } from '../api/types';
export type Action =
| 'patient:read'
| 'alert:read'
| 'careplan:write'
| 'case:advance'
| 'redflag:check'
| 'knowledge:write'
| 'knowledge:ask'
| 'reminder:dispatch'
| 'admin:config'
| 'audit:read';
const MATRIX: Record<Role, Action[]> = {
patient: ['patient:read', 'knowledge:ask', 'redflag:check'],
family: ['patient:read', 'knowledge:ask'],
case_manager: [
'patient:read',
'alert:read',
'careplan:write',
'case:advance',
'redflag:check',
'knowledge:ask',
'knowledge:write',
'reminder:dispatch',
],
physician: [
'patient:read',
'alert:read',
'careplan:write',
'case:advance',
'knowledge:write',
'knowledge:ask',
],
operator: ['patient:read', 'knowledge:write', 'admin:config'],
admin: ['patient:read', 'admin:config', 'audit:read', 'knowledge:write'],
};
export function can(role: Role | undefined, action: Action): boolean {
if (!role) return false;
return MATRIX[role]?.includes(action) ?? false;
}
@@ -0,0 +1,43 @@
import { useEffect, useRef } from 'react';
interface Options {
/** 轮询间隔(毫秒)。默认 15s(工作台更需及时)。 */
intervalMs?: number;
/** 是否启用。默认 true。 */
enabled?: boolean;
}
/**
* 多端数据一致同步(T-8.5):以单一后端为真源,前端通过
* - 窗口 focus / 标签可见(visibilitychange
* - 可见时定时轮询
* 触发静默刷新,使工作台近实时收敛到孕妇/家属/其他医护端的改动。
*
* 注:传入的 refresh 应为"静默"刷新(不触发整页 loading),避免轮询闪烁。
* 实时推送(WebSocket/SSE)列入 V2。
*/
export function useAutoRefresh(refresh: () => void, options?: Options): void {
const intervalMs = options?.intervalMs ?? 15000;
const enabled = options?.enabled ?? true;
const saved = useRef(refresh);
useEffect(() => {
saved.current = refresh;
}, [refresh]);
useEffect(() => {
if (!enabled) return;
const refreshIfVisible = (): void => {
if (document.visibilityState === 'visible') saved.current();
};
const onFocus = (): void => saved.current();
window.addEventListener('focus', onFocus);
document.addEventListener('visibilitychange', refreshIfVisible);
const id = window.setInterval(refreshIfVisible, intervalMs);
return () => {
window.removeEventListener('focus', onFocus);
document.removeEventListener('visibilitychange', refreshIfVisible);
window.clearInterval(id);
};
}, [intervalMs, enabled]);
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
@@ -0,0 +1,98 @@
import { useCallback, useEffect, useState } from 'react';
import { api, ApiError } from '../api/client';
import { useToast } from '../components/Toast';
import type { AuditEntry } from '../api/types';
import { formatTime } from '../lib/format';
const ACTION_LABELS: Record<string, string> = {
'auth:register': '注册',
'auth:login': '登录',
'case:advance': '个案流转',
'careplan:write': '照护计划',
'knowledge:write': '知识录入',
};
function actionLabel(action: string): string {
return ACTION_LABELS[action] ?? action;
}
export function AuditPage(): JSX.Element {
const { show } = useToast();
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [actorId, setActorId] = useState('');
const [action, setAction] = useState('');
const load = useCallback(() => {
setLoading(true);
api
.listAudit({ actorId: actorId.trim() || undefined, action: action.trim() || undefined })
.then(setEntries)
.catch((err) => show(err instanceof ApiError ? err.message : '加载审计日志失败'))
.finally(() => setLoading(false));
}, [actorId, action, show]);
useEffect(() => {
load();
}, [load]);
return (
<div className="stack">
<div>
<h1 style={{ fontSize: 'var(--font-xxl)' }}></h1>
<p className="muted">访</p>
</div>
<div className="card">
<div className="row" style={{ marginBottom: 'var(--space-4)' }}>
<input
placeholder="按操作者 ID 过滤"
value={actorId}
onChange={(e) => setActorId(e.target.value)}
style={{ padding: '8px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
/>
<input
placeholder="按动作过滤,如 auth:login"
value={action}
onChange={(e) => setAction(e.target.value)}
style={{ padding: '8px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
/>
<button className="btn btn-ghost btn-sm" onClick={load} type="button">
</button>
</div>
{loading ? (
<p className="empty"></p>
) : entries.length === 0 ? (
<p className="empty"></p>
) : (
<table className="table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id}>
<td className="muted">{formatTime(e.at)}</td>
<td style={{ fontFamily: 'monospace', fontSize: 'var(--font-sm)' }}>
{e.actorId.slice(0, 12)}
</td>
<td>
<span className="badge badge-neutral">{actionLabel(e.action)}</span>
</td>
<td className="muted">{e.target ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,599 @@
/* 个案工作台详情页高级布局样式 */
/* 档案摘要头 - 紧凑化 */
.patient-head__main {
display: flex;
align-items: center;
gap: var(--space-3);
}
.patient-head__avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--color-primary-soft);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: var(--color-primary-strong);
flex-shrink: 0;
}
.patient-head__name {
font-size: var(--font-lg);
font-weight: 700;
color: var(--color-text-strong);
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.patient-head__factors {
margin-top: var(--space-1);
font-size: var(--font-xs);
color: var(--color-destructive);
}
/* 胶囊 Tab 控制器样式 - 紧凑化 */
.tab-pill-container {
background: var(--color-bg);
border: 1px solid var(--color-border);
padding: 4px;
border-radius: var(--radius-md);
margin: var(--space-2) 0;
}
.tab-pill-group {
display: flex;
gap: 4px;
}
.tab-pill {
flex: 1;
border: none;
background: transparent;
padding: 8px 12px;
font-size: var(--font-sm);
font-weight: 600;
color: var(--color-text-muted);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
text-align: center;
}
.tab-pill.active {
background: var(--color-primary-soft);
color: var(--color-primary-strong);
box-shadow: inset 0 0 0 1px var(--color-primary-soft-border, rgba(0,0,0,0.05));
}
/* 态势感知卡片列表(紧凑 Master 列表) */
.stat-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.stat-item {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s ease;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: space-between;
}
.stat-item:hover {
background: #f8f9fa;
border-color: var(--color-border-strong);
}
.stat-item.active {
background: var(--color-primary-soft);
border-color: var(--color-primary-strong);
}
.stat-item__label {
font-size: var(--font-xs);
color: var(--color-text-strong);
font-weight: 600;
}
.stat-item__val {
font-size: var(--font-md);
font-weight: 700;
line-height: 1.1;
display: flex;
align-items: baseline;
gap: 2px;
}
.stat-unit {
font-size: 10px;
font-weight: 600;
color: var(--color-text-muted);
}
/* 详情列表样式 - 紧凑化 */
.detail-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
overflow-y: auto;
padding-right: 4px;
}
.detail-item {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 8px 12px;
transition: all 0.2s ease;
}
.detail-item:hover {
border-color: var(--color-border-strong);
box-shadow: var(--shadow-sm);
}
.text-rose { color: var(--color-destructive); }
.text-amber { color: var(--color-warning-strong); }
.text-indigo { color: var(--color-primary-strong); }
.text-emerald { color: var(--color-calm); }
/* iPad / 平板响应式双栏工作台布局 (Master-Detail) - 紧凑化 */
.workbench-layout-container {
display: grid;
grid-template-columns: 300px 1fr;
gap: var(--space-3);
align-items: start;
margin: var(--space-3) 0;
}
/* 第一个布局容器(紧贴 Tab)移除上边距 */
.workbench-layout-container:first-of-type {
margin-top: 0;
}
/* Master 面板统一最小高度 - 减小高度 */
.workbench-master {
height: 280px;
overflow-y: auto;
}
/* Detail 面板统一最小高度 - 减小高度 */
.workbench-detail {
height: 280px;
overflow-y: auto;
}
/* 待处置项区域使用固定高度 - 减小高度 */
.disposition-layout .workbench-master {
height: 350px;
overflow-y: auto;
}
.disposition-layout .workbench-detail {
height: 350px;
overflow-y: auto;
}
.pane-title {
font-size: var(--font-sm);
font-weight: 700;
color: var(--color-text-strong);
margin-bottom: var(--space-2);
display: flex;
align-items: center;
gap: var(--space-1);
}
.empty-small {
font-size: var(--font-xs);
color: var(--color-text-muted);
padding: var(--space-4) 0;
text-align: center;
}
/* Master: 待处置项列表 - 紧凑化 */
.work-list-mini {
display: flex;
flex-direction: column;
gap: 6px;
}
.work-item-mini {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 8px 10px;
cursor: pointer;
transition: all 0.2s ease;
background: var(--color-bg);
}
.work-item-mini:hover {
background: #f8f9fa;
border-color: var(--color-border-strong);
}
.work-item-mini.active {
background: var(--color-primary-soft);
border-color: var(--color-primary-strong);
}
.work-item-mini__head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 3px;
}
.priority-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
}
.priority-dot.high { background: var(--color-destructive); }
.priority-dot.medium { background: var(--color-warning-strong); }
.priority-dot.low { background: var(--color-calm); }
.work-item-mini__type {
font-size: var(--font-xs);
font-weight: 700;
color: var(--color-text-strong);
}
.work-item-mini__title {
font-size: var(--font-xs);
line-height: 1.3;
font-weight: 500;
color: var(--color-text);
margin-bottom: 3px;
}
.work-item-mini__date {
font-size: 10px;
color: var(--color-text-muted);
}
/* Detail: 决策处置工作区 - 紧凑化 */
.detail-head {
display: flex;
align-items: center;
gap: var(--space-2);
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--space-2);
margin-bottom: var(--space-2);
}
.priority-tag {
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: var(--radius-sm);
color: #fff;
}
.priority-tag.high { background: var(--color-destructive); }
.priority-tag.medium { background: var(--color-warning-strong); }
.priority-tag.low { background: var(--color-calm); }
.detail-title {
font-size: var(--font-md);
font-weight: 700;
color: var(--color-text-strong);
}
.detail-content-box {
gap: var(--space-3);
}
/* 各类型待办细节面板 */
.alert-desc-box, .emotion-desc-box, .referral-desc-box {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
font-size: var(--font-sm);
line-height: 1.6;
}
.alert-desc-box p, .emotion-desc-box p, .referral-desc-box p {
margin-bottom: 4px;
}
.alert-desc-box p:last-child, .emotion-desc-box p:last-child, .referral-desc-box p:last-child {
margin-bottom: 0;
}
.highlight-val {
font-size: var(--font-lg);
font-weight: 700;
color: var(--color-destructive);
}
.emotion-diary, .clinical-summary-box, .doctor-reply-box {
background: #fff;
border-radius: var(--radius-sm);
padding: 10px 14px;
font-style: italic;
font-size: var(--font-sm);
color: var(--color-text-strong);
margin-top: 6px;
border: 1px dashed var(--color-border);
}
/* AI 建议处置区 */
.ai-advisor {
padding: var(--space-4);
}
.ai-advisor__brand {
font-weight: 700;
font-size: var(--font-sm);
color: var(--color-primary-strong);
display: flex;
align-items: center;
gap: 4px;
}
.ai-advisor__text {
font-size: var(--font-sm);
line-height: 1.5;
margin-bottom: 8px;
}
.ai-actions-container {
display: flex;
flex-direction: column;
}
.ai-action-item {
background: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 10px 14px;
}
/* 门控警告 D.1 / D.2 */
.gating-warning-box {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 12px 16px;
border-radius: var(--radius-md);
font-size: var(--font-sm);
line-height: 1.5;
}
.gating-warning-box.bg-rose-soft {
background: rgba(224, 49, 49, 0.05);
border: 1px solid rgba(224, 49, 49, 0.15);
}
.warn-text {
color: var(--color-destructive);
}
/* 处置动作执行列表 */
.actions-execute-panel {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.execute-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.execute-row__info {
flex: 1;
}
.execute-row__action {
display: flex;
align-items: center;
gap: var(--space-2);
}
.input-inline {
padding: 6px 10px;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
font-size: var(--font-sm);
width: 150px;
}
/* 医生协同会诊板 */
.doctor-collaboration-panel {
padding: var(--space-4);
}
/* 静态生理档案 */
.static-archive-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--space-3);
padding: var(--space-1) 0;
}
.archive-item {
font-size: var(--font-sm);
color: var(--color-text-strong);
}
.archive-label {
color: var(--color-text-muted);
font-weight: 500;
}
/* SVG 折线 Sparkline */
.sparkline {
display: block;
}
.sparkline-wrapper {
margin-top: 8px;
display: flex;
justify-content: center;
}
/* 健康档案指标概览卡片 */
.indicators-summary-grid {
margin-bottom: var(--space-4);
}
.indicator-summary-card {
background: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
display: flex;
flex-direction: column;
}
.indicator-summary-card__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.indicator-label {
font-size: var(--font-xs);
color: var(--color-text-muted);
font-weight: 600;
}
.direction-badge {
font-size: var(--font-xs);
font-weight: 700;
padding: 1px 6px;
border-radius: 4px;
}
.direction-badge.up { background: rgba(224, 49, 49, 0.05); color: var(--color-destructive); }
.direction-badge.down { background: rgba(59, 201, 219, 0.05); color: var(--color-calm); }
.direction-badge.stable { background: #f1f3f5; color: var(--color-text-muted); }
.indicator-summary-card__val {
font-size: var(--font-lg);
font-weight: 700;
color: var(--color-text-strong);
}
.unit-label {
font-size: var(--font-xs);
color: var(--color-text-muted);
}
.huge-stat-box {
display: flex;
flex-direction: column;
align-items: center;
}
.huge-val {
font-size: 34px;
font-weight: 800;
line-height: 1;
}
.animate-fade-in {
animation: fadeIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* 会诊发起卡片美化样式 - 紧凑化 */
.referral-create-card {
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border: 1px solid var(--color-border);
padding: var(--space-3) var(--space-4);
}
.referral-create-header {
margin-bottom: var(--space-3);
}
.referral-create-title {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-1);
}
.referral-create-form {
display: flex;
gap: var(--space-3);
align-items: flex-end;
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.form-group-flex {
flex: 1;
min-width: 0;
}
.form-label {
font-size: 10px;
font-weight: 600;
color: var(--color-text-strong);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.select-modern {
padding: 8px 12px;
border: 2px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--font-xs);
font-weight: 600;
color: var(--color-text-strong);
background: #fff;
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
}
.select-modern:hover {
border-color: var(--color-primary);
}
.select-modern:focus {
outline: none;
border-color: var(--color-primary-strong);
box-shadow: 0 0 0 3px var(--color-primary-soft);
}
.input-group {
display: flex;
gap: var(--space-2);
align-items: center;
}
.input-modern {
flex: 1;
padding: 8px 12px;
border: 2px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--font-xs);
color: var(--color-text-strong);
background: #fff;
transition: all 0.2s ease;
}
.input-modern::placeholder {
color: var(--color-text-muted);
font-style: italic;
}
.input-modern:hover {
border-color: var(--color-border-strong);
}
.input-modern:focus {
outline: none;
border-color: var(--color-primary-strong);
box-shadow: 0 0 0 3px var(--color-primary-soft);
}
.btn-icon {
display: flex;
align-items: center;
gap: var(--space-1);
padding: 8px 16px;
white-space: nowrap;
font-size: var(--font-xs);
}
/* iPad/平板 竖屏及小屏幕适配(竖屏折叠堆叠) */
@media (max-width: 1024px) {
.workbench-layout-container {
grid-template-columns: 1fr; /* 竖屏下转为堆叠布局 */
}
.workbench-master {
max-height: 240px;
overflow-y: auto;
}
}
@media (max-width: 980px) {
.workbench-grid {
grid-template-columns: 1fr;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
.knowledge-grid {
display: grid;
grid-template-columns: 420px 1fr;
gap: var(--space-4);
align-items: start;
}
@media (max-width: 1040px) {
.knowledge-grid {
grid-template-columns: 1fr;
}
}
.knowledge-answer {
background: var(--color-ok-soft);
border-radius: var(--radius-md);
padding: var(--space-3);
font-size: var(--font-sm);
}
.knowledge-answer.is-empty {
background: var(--color-warn-soft);
}
.knowledge-answer p {
white-space: pre-wrap;
}
.knowledge-citations {
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px dashed var(--color-border-strong);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.knowledge-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.knowledge-item {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
}
.knowledge-item__title {
font-weight: 700;
}
.knowledge-item__content {
font-size: var(--font-sm);
margin: var(--space-2) 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.knowledge-item__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.knowledge-kw {
font-size: var(--font-xs);
color: var(--color-calm);
}
.knowledge-item__source {
font-size: var(--font-xs);
margin-top: var(--space-2);
}
@@ -0,0 +1,280 @@
import { useCallback, useEffect, useState } from 'react';
import { api, ApiError } from '../api/client';
import { useToast } from '../components/Toast';
import type {
AuthorityLevel,
CreateKnowledgeInput,
KnowledgeCategory,
KnowledgeItem,
QaAnswer,
} from '../api/types';
import { formatTime } from '../lib/format';
import './KnowledgePage.css';
const CATEGORIES: { value: KnowledgeCategory; label: string }[] = [
{ value: 'guideline', label: '临床指南' },
{ value: 'indicator_reference', label: '指标释义' },
{ value: 'intervention', label: '干预知识' },
{ value: 'tcm', label: '中医调养' },
];
const AUTHORITIES: { value: AuthorityLevel; label: string }[] = [
{ value: 'authoritative', label: '权威' },
{ value: 'reference', label: '参考' },
{ value: 'self', label: '自建' },
];
function categoryLabel(c: string): string {
return CATEGORIES.find((x) => x.value === c)?.label ?? c;
}
function authorityLabel(a: string): string {
return AUTHORITIES.find((x) => x.value === a)?.label ?? a;
}
function authorityBadge(a: AuthorityLevel): string {
return a === 'authoritative' ? 'badge-ok' : a === 'reference' ? 'badge-warn' : 'badge-neutral';
}
export function KnowledgePage(): JSX.Element {
const { show } = useToast();
const [items, setItems] = useState<KnowledgeItem[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState<'all' | KnowledgeCategory>('all');
// 录入表单
const [category, setCategory] = useState<KnowledgeCategory>('guideline');
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [keywords, setKeywords] = useState('');
const [source, setSource] = useState('');
const [authority, setAuthority] = useState<AuthorityLevel>('reference');
const [saving, setSaving] = useState(false);
// 问答测试
const [testQ, setTestQ] = useState('');
const [testAnswer, setTestAnswer] = useState<QaAnswer | null>(null);
const [testing, setTesting] = useState(false);
const load = useCallback(() => {
setLoading(true);
api
.listKnowledge({
q: query.trim() || undefined,
category: categoryFilter === 'all' ? undefined : categoryFilter,
})
.then(setItems)
.catch((err) => show(err instanceof ApiError ? err.message : '加载知识库失败'))
.finally(() => setLoading(false));
}, [query, categoryFilter, show]);
useEffect(() => {
load();
}, [load]);
async function create(e: React.FormEvent): Promise<void> {
e.preventDefault();
if (!title.trim() || !content.trim() || !source.trim()) {
show('标题、内容、来源均为必填');
return;
}
const input: CreateKnowledgeInput = {
category,
title: title.trim(),
content: content.trim(),
keywords: keywords
.split(/[,\s]+/)
.map((k) => k.trim())
.filter(Boolean),
source: source.trim(),
authority,
};
setSaving(true);
try {
await api.createKnowledge(input);
show('知识条目已录入');
setTitle('');
setContent('');
setKeywords('');
setSource('');
load();
} catch (err) {
show(err instanceof ApiError ? err.message : '录入失败');
} finally {
setSaving(false);
}
}
async function runTest(): Promise<void> {
if (!testQ.trim()) return;
setTesting(true);
try {
setTestAnswer(await api.askKnowledge(testQ.trim()));
} catch (err) {
show(err instanceof ApiError ? err.message : '测试失败');
} finally {
setTesting(false);
}
}
return (
<div className="stack">
<div>
<h1 style={{ fontSize: 'var(--font-xxl)' }}></h1>
<p className="muted"></p>
</div>
<div className="knowledge-grid">
{/* 录入 + 测试 */}
<div className="stack">
<form className="card" onSubmit={create}>
<div className="card-header"></div>
<div className="grid-2">
<div className="field">
<label></label>
<select value={category} onChange={(e) => setCategory(e.target.value as KnowledgeCategory)}>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
<div className="field">
<label></label>
<select value={authority} onChange={(e) => setAuthority(e.target.value as AuthorityLevel)}>
{AUTHORITIES.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
</div>
</div>
<div className="field">
<label></label>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="如:妊娠期糖尿病饮食管理" />
</div>
<div className="field">
<label></label>
<textarea
rows={4}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="条目正文(将作为问答依据被引用)"
/>
</div>
<div className="field">
<label></label>
<input value={keywords} onChange={(e) => setKeywords(e.target.value)} placeholder="血糖, 糖尿病, 饮食" />
</div>
<div className="field">
<label></label>
<input value={source} onChange={(e) => setSource(e.target.value)} placeholder="如:XX 临床指南 2024" />
</div>
<button className="btn btn-primary" type="submit" disabled={saving}>
{saving ? '保存中…' : '录入条目'}
</button>
</form>
<div className="card">
<div className="card-header"></div>
<p className="muted" style={{ marginBottom: 'var(--space-3)' }}>
</p>
<div className="row" style={{ marginBottom: 'var(--space-3)' }}>
<input
style={{ flex: 1, padding: '9px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
value={testQ}
onChange={(e) => setTestQ(e.target.value)}
placeholder="输入一个问题,如:孕期血糖高怎么吃"
onKeyDown={(e) => e.key === 'Enter' && runTest()}
/>
<button className="btn btn-calm" onClick={runTest} disabled={testing} type="button">
{testing ? '测试中…' : '测试'}
</button>
</div>
{testAnswer && (
<div className={`knowledge-answer${testAnswer.grounded ? '' : ' is-empty'}`}>
<p>{testAnswer.answer}</p>
{!testAnswer.grounded && <p className="muted"></p>}
{testAnswer.citations.length > 0 && (
<div className="knowledge-citations">
{testAnswer.citations.map((c) => (
<div key={c.id} className="row">
<span className={`badge ${authorityBadge(c.authority)}`}>
{authorityLabel(c.authority)}
</span>
<span>
{c.title} <span className="muted">· {c.source}</span>
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
{/* 列表 */}
<div className="card">
<div className="card-header">
<span>{items.length}</span>
</div>
<div className="row" style={{ marginBottom: 'var(--space-4)' }}>
<input
placeholder="搜索标题/内容/关键词…"
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ flex: 1, padding: '8px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
/>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value as 'all' | KnowledgeCategory)}
style={{ padding: '8px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
>
<option value="all"></option>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
{loading ? (
<p className="empty"></p>
) : items.length === 0 ? (
<p className="empty"></p>
) : (
<div className="knowledge-list">
{items.map((it) => (
<div key={it.id} className="knowledge-item">
<div className="spread">
<span className="knowledge-item__title">{it.title}</span>
<span className={`badge ${authorityBadge(it.authority)}`}>
{authorityLabel(it.authority)}
</span>
</div>
<p className="knowledge-item__content muted">{it.content}</p>
<div className="knowledge-item__meta">
<span className="badge badge-neutral">{categoryLabel(it.category)}</span>
{it.keywords.map((k) => (
<span key={k} className="knowledge-kw">
#{k}
</span>
))}
</div>
<p className="knowledge-item__source muted">
{it.source} · {formatTime(it.createdAt)}
</p>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,103 @@
.login {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #eef2f7, #f7eef3);
padding: var(--space-4);
}
.login__panel {
width: 100%;
max-width: 380px;
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-pop);
}
.login__brand {
text-align: center;
margin-bottom: var(--space-5);
}
.login__logo {
font-size: 44px;
}
.login__brand h1 {
font-size: var(--font-xl);
margin-top: var(--space-2);
}
.login__tabs {
display: flex;
background: var(--color-bg);
border-radius: var(--radius-md);
padding: 4px;
margin-bottom: var(--space-4);
}
.login__tabs button {
flex: 1;
padding: 8px;
border-radius: var(--radius-sm);
font-weight: 600;
color: var(--color-text-soft);
}
.login__tabs button.is-active {
background: var(--color-surface);
color: var(--color-primary-strong);
box-shadow: var(--shadow-card);
}
.login__hint {
margin-top: var(--space-4);
font-size: var(--font-xs);
text-align: center;
}
.login__demo-panel {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px dashed #e2e8f0;
}
.login__demo-title {
font-size: var(--font-sm);
font-weight: 600;
color: #2b6cb0;
margin-bottom: var(--space-2);
}
.login__demo-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.login__demo-grid .btn {
padding: 6px;
font-size: var(--font-xs);
border-radius: var(--radius-sm, 4px);
text-align: center;
}
.login__demo-desc {
font-size: 11px;
line-height: 1.4;
color: #718096;
}
/* SVG 图标 */
.login__logo {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary);
}
.login__demo-title {
display: flex;
align-items: center;
gap: 6px;
}
@@ -0,0 +1,159 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Lightbulb, Stethoscope } from 'lucide-react';
import { useAuth, type StaffRole } from '../auth/AuthContext';
import { useToast } from '../components/Toast';
import { ApiError } from '../api/client';
import './LoginPage.css';
type Mode = 'login' | 'register';
export function LoginPage(): JSX.Element {
const { login, register } = useAuth();
const { show } = useToast();
const navigate = useNavigate();
const [mode, setMode] = useState<Mode>('login');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<StaffRole>('case_manager');
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
if (!username.trim() || !password) {
show('请填写用户名和密码');
return;
}
setSubmitting(true);
try {
if (mode === 'login') {
await login(username.trim(), password);
} else {
await register({ username: username.trim(), password, role });
}
navigate('/worklist', { replace: true });
} catch (err) {
show(err instanceof ApiError ? err.message : err instanceof Error ? err.message : '操作失败');
} finally {
setSubmitting(false);
}
}
function fillDemoUser(usernameVal: string, passwordVal: string, roleVal: StaffRole) {
setUsername(usernameVal);
setPassword(passwordVal);
setRole(roleVal);
}
return (
<div className="login">
<div className="login__panel">
<div className="login__brand">
<span className="login__logo">
<Stethoscope size={40} strokeWidth={1.5} />
</span>
<h1>PCM </h1>
<p className="muted"> · </p>
</div>
<div className="login__tabs">
<button
className={mode === 'login' ? 'is-active' : ''}
onClick={() => setMode('login')}
type="button"
>
</button>
<button
className={mode === 'register' ? 'is-active' : ''}
onClick={() => setMode('register')}
type="button"
>
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="field">
<label></label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入工号 / 用户名"
autoComplete="username"
/>
</div>
<div className="field">
<label></label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
/>
</div>
{mode === 'register' && (
<div className="field">
<label></label>
<select
value={role}
onChange={(e) => setRole(e.target.value as StaffRole)}
>
<option value="case_manager"></option>
<option value="physician"></option>
<option value="operator"></option>
<option value="admin"></option>
</select>
</div>
)}
<button className="btn btn-primary btn-block" type="submit" disabled={submitting}>
{submitting ? '请稍候…' : mode === 'login' ? '登录' : '注册并进入'}
</button>
</form>
<div className="login__demo-panel">
<p className="login__demo-title">
<Lightbulb size={16} strokeWidth={1.75} />
</p>
<div className="login__demo-grid">
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => fillDemoUser('test_manager_01', '12345678', 'case_manager')}
>
</button>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => fillDemoUser('test_doctor_01', '12345678', 'physician')}
>
</button>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => fillDemoUser('test_operator_01', '12345678', 'operator')}
>
</button>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => fillDemoUser('test_admin_01', '12345678', 'admin')}
>
</button>
</div>
<p className="login__demo-desc">
*
</p>
</div>
<p className="muted login__hint">/使使</p>
</div>
</div>
);
}
@@ -0,0 +1,170 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { api, ApiError } from '../api/client';
import { useToast } from '../components/Toast';
import { useAutoRefresh } from '../lib/useAutoRefresh';
import type { PatientSummary, RiskLevel } from '../api/types';
import { riskBadgeClass, riskLabel } from '../lib/format';
const RISK_ORDER: Record<RiskLevel, number> = { high: 0, medium: 1, low: 2 };
const PAGE_SIZE = 10;
export function WorklistPage(): JSX.Element {
const navigate = useNavigate();
const { show } = useToast();
const [patients, setPatients] = useState<PatientSummary[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [riskFilter, setRiskFilter] = useState<'all' | RiskLevel>('all');
const [page, setPage] = useState(1);
const load = useCallback(
(silent = false) => {
if (!silent) setLoading(true);
api
.listPatients()
.then(setPatients)
.catch((err) => {
if (!silent) show(err instanceof ApiError ? err.message : '加载孕妇列表失败');
})
.finally(() => setLoading(false));
},
[show],
);
useEffect(() => {
load();
}, [load]);
// 多端一致:新建档/风险变化近实时反映到工作列表
useAutoRefresh(() => load(true));
const rows = useMemo(() => {
const q = query.trim();
return patients
.filter((p) => (riskFilter === 'all' ? true : p.initialRiskLevel === riskFilter))
.filter((p) => (q ? p.name.includes(q) || (p.patientNo ?? '').toUpperCase().includes(q.toUpperCase()) : true))
.sort((a, b) => RISK_ORDER[a.initialRiskLevel] - RISK_ORDER[b.initialRiskLevel]);
}, [patients, query, riskFilter]);
// 过滤条件变化时回到第一页
useEffect(() => {
setPage(1);
}, [query, riskFilter]);
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pageRows = rows.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
const stats = useMemo(() => {
return {
total: patients.length,
high: patients.filter((p) => p.initialRiskLevel === 'high').length,
medium: patients.filter((p) => p.initialRiskLevel === 'medium').length,
};
}, [patients]);
return (
<div className="stack">
<div className="spread">
<div>
<h1 style={{ fontSize: 'var(--font-xxl)' }}></h1>
<p className="muted"> {stats.total} · {stats.high} · {stats.medium}</p>
</div>
</div>
<div className="card">
<div className="row" style={{ marginBottom: 'var(--space-4)' }}>
<input
placeholder="搜索姓名 / 编号…"
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ maxWidth: 260, padding: '8px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
/>
<select
value={riskFilter}
onChange={(e) => setRiskFilter(e.target.value as 'all' | RiskLevel)}
style={{ padding: '8px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
>
<option value="all"></option>
<option value="high"></option>
<option value="medium"></option>
<option value="low"></option>
</select>
</div>
{loading ? (
<p className="empty"></p>
) : rows.length === 0 ? (
<p className="empty"></p>
) : (
<>
<table className="table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{pageRows.map((p) => (
<tr key={p.id} className="clickable" onClick={() => navigate(`/patients/${p.id}`)}>
<td style={{ fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 'var(--font-sm)' }}>
{p.patientNo || '—'}
</td>
<td style={{ fontWeight: 600 }}>{p.name}</td>
<td>{p.age}</td>
<td>
{p.gestationalWeeks}{p.gestationalDays}
</td>
<td>{trimesterLabel(p.trimester)}</td>
<td>
<span className={`badge ${riskBadgeClass(p.initialRiskLevel)}`}>
{riskLabel(p.initialRiskLevel)}
</span>
</td>
<td className="muted">{p.initialRiskFactors.join('、') || '—'}</td>
</tr>
))}
</tbody>
</table>
<div className="pager">
<span className="muted">
{rows.length} · {currentPage}/{totalPages}
</span>
<div className="pager__btns">
<button
className="btn btn-ghost btn-sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={currentPage <= 1}
type="button"
>
<ChevronLeft size={16} strokeWidth={1.9} />
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage >= totalPages}
type="button"
>
<ChevronRight size={16} strokeWidth={1.9} />
</button>
</div>
</div>
</>
)}
</div>
</div>
);
}
function trimesterLabel(t: string): string {
return { first: '孕早期', second: '孕中期', third: '孕晚期' }[t] ?? t;
}
@@ -0,0 +1,249 @@
@import './tokens.css';
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body,
#root {
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
color: var(--color-text);
background: var(--color-bg);
font-size: var(--font-md);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
input,
select,
textarea {
font-family: inherit;
font-size: var(--font-md);
}
a {
color: inherit;
text-decoration: none;
}
h1,
h2,
h3 {
font-weight: 700;
}
/* 卡片 */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-5);
box-shadow: var(--shadow-card);
}
.card-header {
font-size: var(--font-lg);
font-weight: 700;
margin-bottom: var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
}
/* 按钮 */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: 8px 16px;
border-radius: var(--radius-md);
font-size: var(--font-md);
font-weight: 600;
transition: opacity 0.15s ease, background 0.15s ease;
}
.btn:active {
opacity: 0.85;
}
.btn-primary {
background: var(--color-primary);
color: var(--color-text-inverse);
}
.btn-calm {
background: var(--color-calm);
color: var(--color-text-inverse);
}
.btn-ghost {
background: var(--color-surface);
border: 1px solid var(--color-border-strong);
color: var(--color-text);
}
.btn-sm {
padding: 5px 12px;
font-size: var(--font-sm);
}
.btn-block {
width: 100%;
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* 表单 */
.field {
margin-bottom: var(--space-4);
}
.field label {
display: block;
font-size: var(--font-sm);
color: var(--color-text-soft);
margin-bottom: var(--space-2);
font-weight: 600;
}
.field input,
.field select,
.field textarea {
width: 100%;
padding: 9px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-strong);
background: var(--color-surface);
}
.field input:focus,
.field select:focus,
.field textarea:focus {
outline: none;
border-color: var(--color-calm);
}
/* 表格 */
.table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-md);
}
.table th {
text-align: left;
font-size: var(--font-sm);
color: var(--color-text-soft);
font-weight: 600;
padding: 10px 12px;
border-bottom: 1px solid var(--color-border);
}
.table td {
padding: 12px;
border-bottom: 1px solid var(--color-border);
}
.table tr.clickable {
cursor: pointer;
}
.table tr.clickable:hover td {
background: var(--color-surface-2);
}
/* 徽章 */
.badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: var(--font-xs);
font-weight: 700;
white-space: nowrap;
}
.badge-ok {
background: var(--color-ok-soft);
color: var(--color-ok);
}
.badge-warn {
background: var(--color-warn-soft);
color: var(--color-warn);
}
.badge-danger {
background: var(--color-danger-soft);
color: var(--color-danger);
}
.badge-neutral {
background: var(--color-bg);
color: var(--color-text-soft);
}
.badge-info {
background: var(--color-calm-soft);
color: var(--color-calm);
}
/* 辅助 */
.muted {
color: var(--color-text-soft);
}
.row {
display: flex;
align-items: center;
gap: var(--space-3);
}
.spread {
display: flex;
align-items: center;
justify-content: space-between;
}
.stack {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
.empty {
color: var(--color-text-soft);
font-size: var(--font-sm);
padding: var(--space-4) 0;
}
.toast {
position: fixed;
top: 70px;
right: 24px;
background: var(--color-text);
color: #fff;
padding: 10px 18px;
border-radius: var(--radius-md);
font-size: var(--font-sm);
z-index: 100;
box-shadow: var(--shadow-pop);
max-width: 360px;
}
/* 列表分页 */
.pager {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: var(--space-4);
font-size: var(--font-sm);
}
.pager__btns {
display: flex;
gap: var(--space-2);
}
.pager .btn-sm {
gap: 4px;
}
@@ -0,0 +1,63 @@
/*
* PCM Design Tokens · 医护端(专业工作台)
* 依据 3-ui-style-PCM.md §8:复用品牌主色与字体,但采用信息密度高的专业中性风。
*/
:root {
/* 中性专业底色 */
--color-bg: #f4f6f9;
--color-surface: #ffffff;
--color-surface-2: #fafbfc;
--color-sidebar: #1f2733;
--color-sidebar-soft: #2a3441;
/* 品牌主色(与孕妇端共享) */
--color-primary: #ec6f9e;
--color-primary-strong: #d9568a;
--color-primary-soft: #fdeaf2;
--color-calm: #2f6fb0; /* 专业蓝,强调交互 */
--color-calm-soft: #e8f1fb;
/* 语义色(临床分级) */
--color-ok: #2e9e6b;
--color-ok-soft: #e4f5ec;
--color-warn: #c9821f;
--color-warn-soft: #fbf0d9;
--color-danger: #d2493a;
--color-danger-soft: #fbe4e0;
/* 文本 */
--color-text: #1f2733;
--color-text-soft: #6b7682;
--color-text-faint: #9aa4af;
--color-text-inverse: #ffffff;
--color-border: #e4e8ee;
--color-border-strong: #d2d8e0;
/* 圆角(专业风:中等圆角) */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-pill: 999px;
/* 间距 */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
/* 字号(信息密度高) */
--font-xs: 12px;
--font-sm: 13px;
--font-md: 14px;
--font-lg: 16px;
--font-xl: 20px;
--font-xxl: 26px;
--shadow-card: 0 1px 3px rgba(31, 39, 51, 0.08);
--shadow-pop: 0 8px 24px rgba(31, 39, 51, 0.16);
--sidebar-width: 220px;
--topbar-height: 56px;
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/types.ts","./src/auth/authcontext.tsx","./src/auth/session.ts","./src/components/layout.tsx","./src/components/toast.tsx","./src/components/workbench/alertspanel.tsx","./src/components/workbench/careplanpanel.tsx","./src/components/workbench/caseflowpanel.tsx","./src/components/workbench/caseguidepanel.tsx","./src/components/workbench/casetimeline.tsx","./src/components/workbench/observationspanel.tsx","./src/components/workbench/recommendationpanel.tsx","./src/components/workbench/redflagpanel.tsx","./src/components/workbench/reminderspanel.tsx","./src/lib/format.ts","./src/lib/rbac.ts","./src/lib/useautorefresh.ts","./src/pages/auditpage.tsx","./src/pages/caseworkbenchpage.tsx","./src/pages/knowledgepage.tsx","./src/pages/loginpage.tsx","./src/pages/worklistpage.tsx"],"version":"5.9.3"}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.9.3"}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// 医护端开发服务器:将 /api 代理到本地 NestJS 后端(默认 3000)。
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
proxy: {
'/api': {
target: process.env.PCM_API_TARGET ?? 'http://localhost:3000',
changeOrigin: true,
},
},
},
});
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
coverage
.env
.env.local
*.log
.git
.DS_Store
Dockerfile
.dockerignore
+21
View File
@@ -0,0 +1,21 @@
# PCM 后端环境变量示例(复制为 .env 后填写真实值,勿提交 .env)
PORT=3000
# 鉴权令牌签名密钥(生产必须为强随机值,支持轮换;勿用默认值)
AUTH_SECRET=change-me-to-a-strong-random-secret
# 数据库(PostgreSQL)。
# - 不设置 → 后端使用内存仓储(dev/测试,重启数据丢失)。
# - 设置 → 启用持久化(启动时幂等建表)。本地示例:
# DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/pcm
# 字段级加密密钥(AES-256-GCM32 字节;hex64 或 base64)。
# 生产经 KMS/Secrets 注入并轮换;用于迁移 patient/observation 等敏感字段。
# 生成示例:node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# FIELD_ENCRYPTION_KEY=
# 大模型 / RAG(按所选服务填写)
LLM_API_KEY=
LLM_API_BASE=
# 注意:密钥类变量仅放入本地 .env,不得提交仓库
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
*.log
.env
.env.local
coverage/
.DS_Store
+25
View File
@@ -0,0 +1,25 @@
# PCM 后端生产镜像(多阶段构建)
# 1) build:安装全部依赖并编译 TypeScript → dist
# 2) runtime:仅保留生产依赖与 dist,非 root 运行
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 仅保留生产依赖,缩小运行镜像
RUN npm prune --omit=dev
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# 拷贝生产依赖与编译产物(归属 node 用户)
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/package.json ./package.json
EXPOSE 3000
USER node
# busybox wget 做容器健康检查(见 docker-compose.yml
CMD ["node", "dist/main.js"]
+7
View File
@@ -0,0 +1,7 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
{
"name": "pcm-backend",
"version": "0.1.0",
"description": "PCM 孕产个案管理平台 后端 API",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main.js",
"seed": "ts-node --transpile-only src/seed.ts",
"lint": "eslint \"src/**/*.ts\" --fix",
"test": "jest",
"seed": "ts-node src/seed.ts"
},
"dependencies": {
"@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
"@types/pg": "^8.20.0",
"pg": "^8.21.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.4.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.0",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.16.0",
"eslint": "^8.57.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.0",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"typescript": "^5.5.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"testEnvironment": "node"
}
}
+61
View File
@@ -0,0 +1,61 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { HealthModule } from './modules/health/health.module';
import { PatientModule } from './modules/patient/patient.module';
import { AnalysisModule } from './modules/analysis/analysis.module';
import { ObservationModule } from './modules/observation/observation.module';
import { CaseflowModule } from './modules/caseflow/caseflow.module';
import { NotificationModule } from './modules/notification/notification.module';
import { ReminderModule } from './modules/reminder/reminder.module';
import { RedflagModule } from './modules/redflag/redflag.module';
import { KnowledgeModule } from './modules/knowledge/knowledge.module';
import { AiModule } from './modules/ai/ai.module';
import { AuditModule } from './modules/audit/audit.module';
import { AuthModule } from './modules/auth/auth.module';
import { DispositionModule } from './modules/disposition/disposition.module';
import { FollowupModule } from './modules/followup/followup.module';
import { ReferralModule } from './modules/referral/referral.module';
import { EmotionModule } from './modules/emotion/emotion.module';
import { WorklistModule } from './modules/worklist/worklist.module';
import { JwtAuthGuard } from './common/auth/jwt-auth.guard';
import { CapabilitiesGuard } from './common/auth/capabilities.guard';
import { DatabaseModule } from './common/db/database.module';
/**
* 应用根模块。
* 业务模块(auth/patient/observation/analysis/...)将随阶段逐步接入,
* 详见 4-arch-PCM.md 第 3 节模块划分。
*
* 全局守卫(顺序很重要):
* 1) JwtAuthGuard —— 认证:校验令牌并注入 req.user@Public 放行)。
* 2) CapabilitiesGuard —— 授权:按 @RequireCaps 校验 RBAC 能力,越权拒绝并审计。
*/
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
DatabaseModule,
AuditModule,
NotificationModule,
AuthModule,
HealthModule,
PatientModule,
AnalysisModule,
ObservationModule,
CaseflowModule,
ReminderModule,
RedflagModule,
KnowledgeModule,
AiModule,
DispositionModule,
FollowupModule,
ReferralModule,
EmotionModule,
WorklistModule,
],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: CapabilitiesGuard },
],
})
export class AppModule {}
@@ -0,0 +1,107 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CapabilitiesGuard } from './capabilities.guard';
import { IS_PUBLIC_KEY } from './public.decorator';
import { CAPS_KEY } from './capabilities.decorator';
import { signToken } from '../../modules/auth/token';
import { getAuthSecret } from './auth-secret';
import { AuditService } from '../../modules/audit/audit.service';
import { AuthedRequest } from './request-user';
function ctxWith(req: AuthedRequest): ExecutionContext {
return {
switchToHttp: () => ({ getRequest: () => req }),
getHandler: () => undefined,
getClass: () => undefined,
} as unknown as ExecutionContext;
}
/** 构造一个 reflector,按 key 返回预设元数据。 */
function reflectorWith(meta: Record<string, unknown>): Reflector {
return {
getAllAndOverride: (key: string) => meta[key],
} as unknown as Reflector;
}
function bearer(token: string): AuthedRequest {
return { headers: { authorization: `Bearer ${token}` } };
}
describe('JwtAuthGuard(认证守卫)', () => {
const validToken = (): string =>
signToken(
{ sub: 'u1', role: 'case_manager', exp: Math.floor(Date.now() / 1000) + 3600 },
getAuthSecret(),
);
it('@Public 端点跳过认证', () => {
const guard = new JwtAuthGuard(reflectorWith({ [IS_PUBLIC_KEY]: true }));
const req: AuthedRequest = { headers: {} };
expect(guard.canActivate(ctxWith(req))).toBe(true);
});
it('有效令牌 → 通过并注入 req.user', () => {
const guard = new JwtAuthGuard(reflectorWith({}));
const req = bearer(validToken());
expect(guard.canActivate(ctxWith(req))).toBe(true);
expect(req.user).toEqual({ id: 'u1', role: 'case_manager' });
});
it('缺少令牌 → 401', () => {
const guard = new JwtAuthGuard(reflectorWith({}));
expect(() => guard.canActivate(ctxWith({ headers: {} }))).toThrow(UnauthorizedException);
});
it('无效令牌 → 401', () => {
const guard = new JwtAuthGuard(reflectorWith({}));
expect(() => guard.canActivate(ctxWith(bearer('garbage.token')))).toThrow(UnauthorizedException);
});
it('过期令牌 → 401', () => {
const expired = signToken(
{ sub: 'u1', role: 'patient', exp: Math.floor(Date.now() / 1000) - 10 },
getAuthSecret(),
);
const guard = new JwtAuthGuard(reflectorWith({}));
expect(() => guard.canActivate(ctxWith(bearer(expired)))).toThrow(UnauthorizedException);
});
});
describe('CapabilitiesGuard(授权守卫)', () => {
let audit: AuditService;
beforeEach(() => {
audit = new AuditService();
});
it('无能力声明 → 放行', () => {
const guard = new CapabilitiesGuard(reflectorWith({}), audit);
const req: AuthedRequest = { headers: {}, user: { id: 'u1', role: 'patient' } };
expect(guard.canActivate(ctxWith(req))).toBe(true);
});
it('具备能力 → 放行', () => {
const guard = new CapabilitiesGuard(reflectorWith({ [CAPS_KEY]: ['careplan:write'] }), audit);
const req: AuthedRequest = { headers: {}, user: { id: 'cm', role: 'case_manager' } };
expect(guard.canActivate(ctxWith(req))).toBe(true);
});
it('越权 → 403 并记录审计 access:deny', async () => {
const guard = new CapabilitiesGuard(reflectorWith({ [CAPS_KEY]: ['audit:read'] }), audit);
const req: AuthedRequest = { headers: {}, user: { id: 'mom', role: 'patient' } };
expect(() => guard.canActivate(ctxWith(req))).toThrow(ForbiddenException);
const denials = await audit.query({ action: 'access:deny' });
expect(denials).toHaveLength(1);
expect(denials[0].actorId).toBe('mom');
});
it('孕妇可自助建档/自设提醒/查看本人预警', () => {
const guard = (caps: string[]): CapabilitiesGuard =>
new CapabilitiesGuard(reflectorWith({ [CAPS_KEY]: caps }), audit);
const req: AuthedRequest = { headers: {}, user: { id: 'mom', role: 'patient' } };
expect(guard(['patient:create']).canActivate(ctxWith(req))).toBe(true);
expect(guard(['reminder:dispatch']).canActivate(ctxWith(req))).toBe(true);
expect(guard(['alert:read']).canActivate(ctxWith(req))).toBe(true);
});
});
@@ -0,0 +1,7 @@
/**
* 鉴权密钥单一来源(AuthService 与 JwtAuthGuard 共用,避免漂移)。
* 生产环境必须通过环境变量注入强随机密钥,并支持轮换。
*/
export function getAuthSecret(): string {
return process.env.AUTH_SECRET ?? 'dev-secret-change-me';
}
@@ -0,0 +1,11 @@
import { SetMetadata } from '@nestjs/common';
import { Action } from '../../modules/auth/rbac';
export const CAPS_KEY = 'pcm:caps';
/**
* 声明访问该端点所需的能力(RBAC action)。
* 多个能力时需全部满足。由 CapabilitiesGuard 校验。
*/
export const RequireCaps = (...caps: Action[]): MethodDecorator & ClassDecorator =>
SetMetadata(CAPS_KEY, caps);
@@ -0,0 +1,45 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CAPS_KEY } from './capabilities.decorator';
import { AuthedRequest } from './request-user';
import { Action, can } from '../../modules/auth/rbac';
import { AuditService } from '../../modules/audit/audit.service';
/**
* 全局授权守卫(T-2.2 分级权限 / 越权拒绝并审计)。
* 读取 @RequireCaps 声明的能力,校验当前用户角色是否具备(能力级 RBAC)。
* 越权访问被拒绝(403)并写入审计(access:deny)。
*
* 注:仅做能力级(action)授权;记录级(仅本人/负责个案/绑定孕妇)
* 由各服务结合上下文校验(见 rbac.ts 说明),属已知后续项。
*/
@Injectable()
export class CapabilitiesGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly audit: AuditService,
) {}
canActivate(context: ExecutionContext): boolean {
const caps = this.reflector.getAllAndOverride<Action[]>(CAPS_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!caps || caps.length === 0) return true;
const req = context.switchToHttp().getRequest<AuthedRequest>();
const user = req.user;
// 未认证(理论上 JwtAuthGuard 已拦截;双保险)
if (!user) {
throw new ForbiddenException('未认证');
}
const missing = caps.filter((cap) => !can(user.role, cap));
if (missing.length > 0) {
this.audit.record(user.id, 'access:deny', missing.join(','));
throw new ForbiddenException('无权限执行该操作');
}
return true;
}
}
@@ -0,0 +1,10 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthedRequest, RequestUser } from './request-user';
/** 取出经认证的当前用户(由 JwtAuthGuard 注入)。 */
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): RequestUser | undefined => {
const req = ctx.switchToHttp().getRequest<AuthedRequest>();
return req.user;
},
);
@@ -0,0 +1,49 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { verifyToken } from '../../modules/auth/token';
import { getAuthSecret } from './auth-secret';
import { IS_PUBLIC_KEY } from './public.decorator';
import { AuthedRequest, RequestUser } from './request-user';
import { Role } from '../../modules/auth/rbac';
/**
* 全局认证守卫(T-2.1 会话安全)。
* 校验 Authorization: Bearer <token> 的签名与过期,注入 req.user。
* @Public 端点跳过认证(登录/注册/健康检查)。
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const req = context.switchToHttp().getRequest<AuthedRequest>();
const token = extractBearer(req);
if (!token) {
throw new UnauthorizedException('缺少访问令牌');
}
const payload = verifyToken(token, getAuthSecret());
if (!payload) {
throw new UnauthorizedException('令牌无效或已过期');
}
const user: RequestUser = { id: payload.sub, role: payload.role as Role };
req.user = user;
return true;
}
}
function extractBearer(req: AuthedRequest): string | null {
const header = req.headers['authorization'];
const value = Array.isArray(header) ? header[0] : header;
if (!value) return null;
const [scheme, token] = value.split(' ');
if (scheme?.toLowerCase() !== 'bearer' || !token) return null;
return token.trim();
}
@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'pcm:isPublic';
/** 标注端点为公开(跳过认证),用于登录/注册/健康检查。 */
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,13 @@
import { Role } from '../../modules/auth/rbac';
/** 经 JwtAuthGuard 校验后注入 request 的当前用户标识。 */
export interface RequestUser {
id: string;
role: Role;
}
/** 带已认证用户的请求(guard 注入)。 */
export interface AuthedRequest {
user?: RequestUser;
headers: Record<string, string | string[] | undefined>;
}
@@ -0,0 +1,54 @@
import { decryptField, encryptField, fieldKeyFromEnv, isEncrypted } from './field-crypto';
import { randomBytes } from 'node:crypto';
describe('field-crypto(字段级加密 AES-256-GCM', () => {
const key = randomBytes(32);
it('加密后可解密还原(round-trip', () => {
const plain = '空腹血糖 6.2 mmol/L · 孕妇隐私字段';
const token = encryptField(plain, key);
expect(token).not.toContain(plain);
expect(isEncrypted(token)).toBe(true);
expect(decryptField(token, key)).toBe(plain);
});
it('相同明文每次密文不同(随机 IV)', () => {
const a = encryptField('same', key);
const b = encryptField('same', key);
expect(a).not.toBe(b);
expect(decryptField(a, key)).toBe('same');
expect(decryptField(b, key)).toBe('same');
});
it('被篡改的密文解密失败(GCM 认证)', () => {
const token = encryptField('secret', key);
const parts = token.split(':');
const tampered = Buffer.from(parts[3], 'base64');
tampered[0] ^= 0xff;
parts[3] = tampered.toString('base64');
expect(() => decryptField(parts.join(':'), key)).toThrow();
});
it('错误密钥解密失败', () => {
const token = encryptField('secret', key);
expect(() => decryptField(token, randomBytes(32))).toThrow();
});
it('非法密钥长度抛错', () => {
expect(() => encryptField('x', randomBytes(16))).toThrow();
});
it('密文格式非法抛错', () => {
expect(() => decryptField('not-a-token', key)).toThrow('密文格式无效');
});
it('fieldKeyFromEnv:未配置返回 nullhex64 解析为 32 字节', () => {
const prev = process.env.FIELD_ENCRYPTION_KEY;
delete process.env.FIELD_ENCRYPTION_KEY;
expect(fieldKeyFromEnv()).toBeNull();
process.env.FIELD_ENCRYPTION_KEY = randomBytes(32).toString('hex');
expect(fieldKeyFromEnv()?.length).toBe(32);
if (prev === undefined) delete process.env.FIELD_ENCRYPTION_KEY;
else process.env.FIELD_ENCRYPTION_KEY = prev;
});
});
@@ -0,0 +1,65 @@
/**
* 字段级加密工具(T-1.2 / NFR-1 存储加密)。
* AES-256-GCM:随机 IV + 认证标签,防篡改。纯函数,便于单测。
*
* 令牌格式:`v1:<base64 iv>:<base64 authTag>:<base64 ciphertext>`
* 密钥:32 字节(256 bit)。生产经 KMS/Secrets 注入并轮换。
*
* 适用:迁移 patient/observation 等含敏感健康数据的仓储时,对敏感字段加密入库。
* 口令为不可逆哈希(password.ts),无需此处加密。
*/
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const ALGO = 'aes-256-gcm';
const IV_LEN = 12; // GCM 推荐 96-bit IV
const VERSION = 'v1';
export function encryptField(plaintext: string, key: Buffer): string {
assertKey(key);
const iv = randomBytes(IV_LEN);
const cipher = createCipheriv(ALGO, key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [VERSION, iv.toString('base64'), tag.toString('base64'), ciphertext.toString('base64')].join(
':',
);
}
export function decryptField(token: string, key: Buffer): string {
assertKey(key);
const parts = token.split(':');
if (parts.length !== 4 || parts[0] !== VERSION) {
throw new Error('密文格式无效');
}
const iv = Buffer.from(parts[1], 'base64');
const tag = Buffer.from(parts[2], 'base64');
const ciphertext = Buffer.from(parts[3], 'base64');
const decipher = createDecipheriv(ALGO, key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
}
/** 是否为已加密令牌(用于读路径的兼容判断)。 */
export function isEncrypted(value: string): boolean {
return typeof value === 'string' && value.startsWith(`${VERSION}:`) && value.split(':').length === 4;
}
/**
* 从环境变量读取字段加密密钥(hex 64 / base64)。未配置返回 null。
* 生产:经 KMS/Secrets 注入强随机 32 字节密钥并支持轮换。
*/
export function fieldKeyFromEnv(): Buffer | null {
const raw = process.env.FIELD_ENCRYPTION_KEY?.trim();
if (!raw) return null;
const key = /^[0-9a-fA-F]{64}$/.test(raw) ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64');
if (key.length !== 32) {
throw new Error('FIELD_ENCRYPTION_KEY 必须为 32 字节(hex64 或 base64');
}
return key;
}
function assertKey(key: Buffer): void {
if (!Buffer.isBuffer(key) || key.length !== 32) {
throw new Error('字段加密密钥必须为 32 字节');
}
}
@@ -0,0 +1,29 @@
import { FieldSealer } from './field-sealer';
import { randomBytes } from 'node:crypto';
import { isEncrypted } from './field-crypto';
describe('FieldSealer(敏感字段封装)', () => {
const payload = { name: '小雅', historyGdm: true, value: 6.2 };
it('配置密钥 → 密文入库,可还原', () => {
const sealer = new FieldSealer(randomBytes(32));
const sealed = sealer.seal(payload);
expect(isEncrypted(sealed)).toBe(true);
expect(sealed).not.toContain('小雅');
expect(sealer.open(sealed)).toEqual(payload);
});
it('未配置密钥 → 明文 JSONdev),可还原', () => {
const sealer = new FieldSealer(null);
const sealed = sealer.seal(payload);
expect(isEncrypted(sealed)).toBe(false);
expect(sealer.open(sealed)).toEqual(payload);
expect(sealer.enabled).toBe(false);
});
it('密文数据但无密钥 → 解密报错', () => {
const enc = new FieldSealer(randomBytes(32)).seal(payload);
const noKey = new FieldSealer(null);
expect(() => noKey.open(enc)).toThrow();
});
});
@@ -0,0 +1,35 @@
import { decryptField, encryptField, fieldKeyFromEnv, isEncrypted } from './field-crypto';
/**
* 字段封装器(T-1.2):把敏感字段集合序列化为单个"密封"字符串入库。
* - 配置 FIELD_ENCRYPTION_KEY → AES-256-GCM 加密(密文入库);
* - 未配置 → 明文 JSON 入库(仅 dev;生产必须配置密钥)。
* 读路径按令牌格式自动识别加解密,兼容历史明文。
*/
export class FieldSealer {
constructor(private readonly key: Buffer | null) {}
get enabled(): boolean {
return this.key !== null;
}
seal(payload: unknown): string {
const json = JSON.stringify(payload);
return this.key ? encryptField(json, this.key) : json;
}
open<T>(sealed: string): T {
if (isEncrypted(sealed)) {
if (!this.key) {
throw new Error('数据为密文但未配置 FIELD_ENCRYPTION_KEY,无法解密');
}
return JSON.parse(decryptField(sealed, this.key)) as T;
}
return JSON.parse(sealed) as T;
}
}
/** 从环境变量构造封装器(FIELD_ENCRYPTION_KEY 缺省则为明文模式)。 */
export function createSealerFromEnv(): FieldSealer {
return new FieldSealer(fieldKeyFromEnv());
}
@@ -0,0 +1,41 @@
import { Global, Inject, Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Pool } from 'pg';
import { PG_POOL, isDatabaseEnabled } from './db.tokens';
import { runSchemaBootstrap } from './schema';
/**
* 全局数据库模块(T-1.2 / T-0.3 持久化)。
* - 设置 DATABASE_URL → 创建 pg 连接池,启动时幂等建表;
* - 未设置 → 提供 null,业务模块回退内存仓储(dev/测试)。
*/
@Global()
@Module({
providers: [
{
provide: PG_POOL,
useFactory: (): Pool | null => {
if (!isDatabaseEnabled()) return null;
return new Pool({ connectionString: process.env.DATABASE_URL });
},
},
],
exports: [PG_POOL],
})
export class DatabaseModule implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger('Database');
constructor(@Inject(PG_POOL) private readonly pool: Pool | null) {}
async onModuleInit(): Promise<void> {
if (!this.pool) {
this.logger.log('DATABASE_URL 未设置:使用内存仓储(dev/测试)');
return;
}
await runSchemaBootstrap(this.pool);
this.logger.log('PostgreSQL 已连接,schema 就绪');
}
async onModuleDestroy(): Promise<void> {
if (this.pool) await this.pool.end();
}
}
@@ -0,0 +1,7 @@
/** PG 连接池注入令牌;未启用 DB 时该提供者为 null。 */
export const PG_POOL = 'PG_POOL';
/** 是否启用 PostgreSQL 持久化(由 DATABASE_URL 决定)。 */
export function isDatabaseEnabled(): boolean {
return Boolean(process.env.DATABASE_URL && process.env.DATABASE_URL.trim());
}
@@ -0,0 +1,208 @@
import { Pool } from 'pg';
/**
* 幂等 schema 引导(MVP)。生产应改用受控迁移工具(如 node-pg-migrate / Flyway)。
* 仅在 DATABASE_URL 启用时由 DatabaseModule 调用。
*/
export async function runSchemaBootstrap(pool: Pool): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY,
username text UNIQUE NOT NULL,
password_hash text NOT NULL,
role text NOT NULL,
consent_signed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS audit_log (
id uuid PRIMARY KEY,
actor_id text NOT NULL,
action text NOT NULL,
target text,
at timestamptz NOT NULL DEFAULT now()
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log (action);`);
// 孕妇档案:敏感 PII/PHI 加密入 enc 列;仅保留非敏感可查询元数据为列(T-1.2 字段级加密)
await pool.query(`
CREATE TABLE IF NOT EXISTS patients (
id uuid PRIMARY KEY,
patient_no text,
initial_risk_level text NOT NULL,
enc text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
`);
// 既有库幂等补列(人类可读编号)
await pool.query(`ALTER TABLE patients ADD COLUMN IF NOT EXISTS patient_no text;`);
await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_patient_no ON patients (patient_no);`);
// 观测值:value 等敏感测量加密入 enc 列;指标/孕周/时间等元数据为列以支持检索排序
await pool.query(`
CREATE TABLE IF NOT EXISTS observations (
id uuid PRIMARY KEY,
patient_id uuid NOT NULL,
indicator text NOT NULL,
unit text NOT NULL,
measured_at timestamptz NOT NULL,
source text NOT NULL,
qc_status text NOT NULL,
gestational_weeks int NOT NULL,
enc text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_obs_patient ON observations (patient_id);`);
// 预警:value/规则/说明等敏感内容加密入 enclevel/status/indicator 留列以供检索
await pool.query(`
CREATE TABLE IF NOT EXISTS alerts (
id uuid PRIMARY KEY,
patient_id uuid NOT NULL,
observation_id uuid,
indicator text NOT NULL,
level text NOT NULL,
status text NOT NULL,
enc text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_alert_patient ON alerts (patient_id);`);
// 个案:流转历史(含原因)加密入 enc;阶段/状态/风险/负责管理师留列
await pool.query(`
CREATE TABLE IF NOT EXISTS cases (
id uuid PRIMARY KEY,
patient_id uuid NOT NULL,
case_manager_id text,
stage text NOT NULL,
status text NOT NULL,
risk_level text NOT NULL,
enc text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_case_patient ON cases (patient_id);`);
// 照护计划:目标/干预等临床内容加密入 enccase/patient/status 留列
await pool.query(`
CREATE TABLE IF NOT EXISTS care_plans (
id uuid PRIMARY KEY,
case_id uuid NOT NULL,
patient_id uuid NOT NULL,
status text NOT NULL,
enc text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_plan_case ON care_plans (case_id);`);
// 提醒:文案加密入 enc;类型/时间等留列
await pool.query(`
CREATE TABLE IF NOT EXISTS reminders (
id uuid PRIMARY KEY,
patient_id uuid NOT NULL,
type text NOT NULL,
effective_type text NOT NULL,
adjusted_for_risk boolean NOT NULL DEFAULT false,
scheduled_at timestamptz NOT NULL,
enc text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_reminder_patient ON reminders (patient_id);`);
// 知识库:权威可公开内容(A-1),明文存储以支持全文/关键词检索
await pool.query(`
CREATE TABLE IF NOT EXISTS knowledge_items (
id uuid PRIMARY KEY,
category text NOT NULL,
title text NOT NULL,
content text NOT NULL,
keywords text[] NOT NULL DEFAULT '{}',
source text NOT NULL,
authority text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
`);
// 处置单(REQ-D1):标题/来源ID/动作明细加密入 enc;状态/风险/确认/创建人等留列供检索与门控
await pool.query(`
CREATE TABLE IF NOT EXISTS dispositions (
id uuid PRIMARY KEY,
case_id uuid NOT NULL,
patient_id uuid NOT NULL,
source_type text NOT NULL,
status text NOT NULL,
risk_level text NOT NULL,
requires_confirmation boolean NOT NULL DEFAULT false,
closure_outcome text,
supersedes_id uuid,
created_by text NOT NULL,
confirmed_by text,
enc text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz
);
`);
await pool.query(
`CREATE INDEX IF NOT EXISTS idx_disposition_patient ON dispositions (patient_id);`,
);
// 跟进项(T-D.2):目标/复测关联加密入 enc;指标/状态/到期留列
await pool.query(`
CREATE TABLE IF NOT EXISTS followups (
id uuid PRIMARY KEY,
disposition_id uuid NOT NULL,
patient_id uuid NOT NULL,
indicator text NOT NULL,
status text NOT NULL,
outcome text,
enc text NOT NULL,
due_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
evaluated_at timestamptz
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_followup_patient ON followups (patient_id);`);
// 转诊/会诊(T-D.3):诊断陈述/回复/意见加密入 enc;状态/指派医生/紧急度留列供检索
await pool.query(`
CREATE TABLE IF NOT EXISTS referrals (
id uuid PRIMARY KEY,
patient_id uuid NOT NULL,
disposition_id uuid,
type text NOT NULL,
status text NOT NULL,
urgency text NOT NULL,
from_manager_id text NOT NULL,
to_doctor_id text NOT NULL,
enc text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_referral_patient ON referrals (patient_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_referral_doctor ON referrals (to_doctor_id);`);
// 情绪打卡(T-D.4):日记/日记密文入 enc;分值/信号状态留列
await pool.query(`
CREATE TABLE IF NOT EXISTS emotions (
id uuid PRIMARY KEY,
patient_id uuid NOT NULL,
score int NOT NULL,
status text NOT NULL,
enc text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_emotion_patient ON emotions (patient_id);`);
}
@@ -0,0 +1,107 @@
/**
* 端到端闭环集成测试(T-9.2 / PRD §5.1)。
* 串联:建档 → 录入偏高血糖 → 质控 → 规则分析 → 预警 → 个案流转
* → AI 决策建议 → 知识问答 → 红旗急症。
*/
import { PatientService } from '../modules/patient/patient.service';
import { InMemoryPatientRepository } from '../modules/patient/patient.repository';
import { AnalysisService } from '../modules/analysis/analysis.service';
import { InMemoryAlertRepository } from '../modules/analysis/alert.repository';
import { CaseflowService } from '../modules/caseflow/caseflow.service';
import { InMemoryCaseflowRepository } from '../modules/caseflow/caseflow.repository';
import { ObservationService } from '../modules/observation/observation.service';
import { InMemoryObservationRepository } from '../modules/observation/observation.repository';
import { KnowledgeService } from '../modules/knowledge/knowledge.service';
import { InMemoryKnowledgeRepository } from '../modules/knowledge/knowledge.repository';
import { AiService } from '../modules/ai/ai.service';
import { NotificationService } from '../modules/notification/notification.service';
import { RedflagService } from '../modules/redflag/redflag.service';
import { FollowupService } from '../modules/followup/followup.service';
import { InMemoryFollowupRepository } from '../modules/followup/followup.repository';
describe('GDM 闭环集成', () => {
let patientService: PatientService;
let analysisService: AnalysisService;
let caseflowService: CaseflowService;
let observationService: ObservationService;
let aiService: AiService;
let knowledgeService: KnowledgeService;
let redflagService: RedflagService;
let notification: NotificationService;
beforeEach(() => {
patientService = new PatientService(new InMemoryPatientRepository());
analysisService = new AnalysisService(new InMemoryAlertRepository());
caseflowService = new CaseflowService(new InMemoryCaseflowRepository());
observationService = new ObservationService(
new InMemoryObservationRepository(),
patientService,
analysisService,
caseflowService,
new FollowupService(new InMemoryFollowupRepository()),
);
knowledgeService = new KnowledgeService(new InMemoryKnowledgeRepository());
aiService = new AiService(knowledgeService, analysisService, caseflowService);
notification = new NotificationService();
redflagService = new RedflagService(notification, caseflowService);
});
it('完整闭环:偏高血糖触发预警、个案流转与需人工确认的建议', async () => {
// 1. 建档(高龄 → medium 基线)
const patient = await patientService.create({
name: '小雅',
age: 36,
heightCm: 160,
prePregnancyWeightKg: 60,
lmp: '2026-01-01',
});
// 2. 录入偏高空腹血糖 → 质控通过 → 分析 → 预警
const rec = await observationService.record(patient.id, {
indicator: 'fasting_glucose',
value: 5.6,
});
expect(rec.observation.qcStatus).toBe('accepted');
expect(rec.alert?.level).toBe('medium');
// 3. 个案被预警驱动(自动开案)
const c = await caseflowService.getCaseByPatient(patient.id);
expect(c.riskLevel).toBe('medium');
// 4. AI 决策建议(中风险 → 需人工确认)
const reco = await aiService.recommendForPatient(patient.id);
expect(reco.requiresHumanConfirmation).toBe(true);
expect(reco.rationale.length).toBeGreaterThan(0);
// 5. 知识问答(带溯源、不超纲)
await knowledgeService.create({
category: 'guideline',
title: '妊娠期糖尿病饮食',
content: '控制碳水、少食多餐。',
keywords: ['血糖', '饮食'],
source: '权威指南',
authority: 'authoritative',
});
const ans = await aiService.ask('血糖偏高如何饮食');
expect(ans.grounded).toBe(true);
expect(ans.citations[0].source).toBe('权威指南');
});
it('红旗急症:立即就医提示并升级个案为高风险', async () => {
const patient = await patientService.create({ name: '小李', age: 30, lmp: '2026-01-01' });
const result = await redflagService.evaluate(patient.id, {
systolicBp: 165,
symptoms: ['severe_headache'],
});
expect(result.triggered).toBe(true);
expect(result.patientAdvice).toContain('就医');
// 孕妇收到紧急通知
const urgent = notification.findByRecipient(patient.id).filter((m) => m.urgent);
expect(urgent.length).toBeGreaterThan(0);
// 个案升级为高风险
const c = await caseflowService.getCaseByPatient(patient.id);
expect(c.riskLevel).toBe('high');
});
});
@@ -0,0 +1,261 @@
/**
* 端到端场景演练(T-9.2 / PRD §3.2 核心场景 S1–S7、§5.1 闭环、§7 上线判据)。
*
* 在服务层串联各模块,覆盖 V1 可上线场景的实质闭环;
* 标注为 V2/V3 的能力(趋势预测、游戏化)以 it.todo 记录,不在 V1 断言。
*
* 复用既有内存仓储与服务,构造共享依赖的系统实例(makeSystem)。
*/
import { PatientService } from '../modules/patient/patient.service';
import { InMemoryPatientRepository } from '../modules/patient/patient.repository';
import { AnalysisService } from '../modules/analysis/analysis.service';
import { InMemoryAlertRepository } from '../modules/analysis/alert.repository';
import { CaseflowService } from '../modules/caseflow/caseflow.service';
import { InMemoryCaseflowRepository } from '../modules/caseflow/caseflow.repository';
import { ObservationService } from '../modules/observation/observation.service';
import { InMemoryObservationRepository } from '../modules/observation/observation.repository';
import { KnowledgeService } from '../modules/knowledge/knowledge.service';
import { InMemoryKnowledgeRepository } from '../modules/knowledge/knowledge.repository';
import { AiService } from '../modules/ai/ai.service';
import { NotificationService } from '../modules/notification/notification.service';
import { RedflagService } from '../modules/redflag/redflag.service';
import { ReminderService } from '../modules/reminder/reminder.service';
import { FollowupService } from '../modules/followup/followup.service';
import { InMemoryFollowupRepository } from '../modules/followup/followup.repository';
function makeSystem() {
const notification = new NotificationService();
const followupService = new FollowupService(new InMemoryFollowupRepository());
const patientService = new PatientService(new InMemoryPatientRepository());
const analysisService = new AnalysisService(new InMemoryAlertRepository());
const caseflowService = new CaseflowService(new InMemoryCaseflowRepository());
const observationService = new ObservationService(
new InMemoryObservationRepository(),
patientService,
analysisService,
caseflowService,
followupService,
);
const knowledgeService = new KnowledgeService(new InMemoryKnowledgeRepository());
const aiService = new AiService(knowledgeService, analysisService, caseflowService);
const redflagService = new RedflagService(notification, caseflowService);
const reminderService = new ReminderService(patientService, notification);
return {
notification,
patientService,
analysisService,
caseflowService,
observationService,
knowledgeService,
aiService,
redflagService,
reminderService,
};
}
describe('PRD §3.2 核心场景端到端演练(T-9.2', () => {
describe('S1 居家测血糖/血压后自动分析(REQ-1/3)', () => {
it('血糖录入 → 质控通过 → 规则分析 → 可追溯预警', async () => {
const sys = makeSystem();
const patient = await sys.patientService.create({ name: '小雅', age: 30, lmp: '2026-01-01' });
const rec = await sys.observationService.record(patient.id, {
indicator: 'fasting_glucose',
value: 5.6,
});
expect(rec.observation.qcStatus).toBe('accepted');
expect(rec.alert).not.toBeNull();
expect(rec.alert?.level).toBe('medium');
// 可解释 / 可追溯(NFR-3):命中规则 + 关联观测值
expect(rec.alert?.ruleIds.length).toBeGreaterThan(0);
expect(rec.alert?.observationId).toBe(rec.observation.id);
});
it('血压录入 → 升高生成预警', async () => {
const sys = makeSystem();
const patient = await sys.patientService.create({ name: '小雅', age: 30, lmp: '2026-01-01' });
const rec = await sys.observationService.record(patient.id, {
indicator: 'systolic_bp',
value: 150,
});
expect(rec.observation.qcStatus).toBe('accepted');
expect(rec.alert?.level).toBe('medium');
});
it('不可信数值 → 质控拦截,不驱动高风险结论(C-5)', async () => {
const sys = makeSystem();
const patient = await sys.patientService.create({ name: '小雅', age: 30, lmp: '2026-01-01' });
const rec = await sys.observationService.record(patient.id, {
indicator: 'fasting_glucose',
value: 50, // 超出生理合理范围
});
expect(rec.observation.qcStatus).toBe('rejected');
expect(rec.alert).toBeNull();
});
});
describe('S2 GDM 风险孕妇的日常管理闭环(REQ-6/9/10', () => {
it('建档→预警→自动开案→指派→流转→AI建议(需人工确认)→照护计划→提醒', async () => {
const sys = makeSystem();
// 既往 GDM → 基线 medium
const patient = await sys.patientService.create({
name: '小雅',
age: 31,
lmp: '2026-01-01',
historyGdm: true,
});
expect(patient.initialRiskLevel).toBe('medium');
// 录入偏高血糖 → 预警 → 预警驱动开案
const rec = await sys.observationService.record(patient.id, {
indicator: 'fasting_glucose',
value: 6.2,
});
expect(rec.alert).not.toBeNull();
const c = await sys.caseflowService.getCaseByPatient(patient.id);
expect(c.status).toBe('open');
expect(c.riskLevel).toBe('medium');
// 指派管理师 + 推进阶段
await sys.caseflowService.assignManager(c.id, 'cm-1');
const advanced = await sys.caseflowService.advanceStage(c.id, 'assessment', '评估');
expect(advanced.stage).toBe('assessment');
expect(advanced.caseManagerId).toBe('cm-1');
// AI 决策建议:中风险 → 必须人工确认(REQ-10.3)
const reco = await sys.aiService.recommendForPatient(patient.id);
expect(reco.requiresHumanConfirmation).toBe(true);
expect(reco.actions.length).toBeGreaterThan(0);
// 照护计划
const plan = await sys.caseflowService.createCarePlan(c.id, {
goals: ['空腹血糖 < 5.1 mmol/L'],
interventions: [{ kind: 'lifestyle', description: '饮食控制 + 餐后散步' }],
followUpFrequency: 'weekly',
});
expect(plan.goals.length).toBe(1);
// 提醒下发
const reminder = await sys.reminderService.dispatch({ patientId: patient.id, type: 'measurement' });
expect(reminder.effectiveType).toBe('measurement');
expect(sys.notification.findByRecipient(patient.id).length).toBeGreaterThan(0);
});
});
describe('S3 子痫前期红旗症状旁路(REQ-5', () => {
it('危急组合 → 即时就医提示 + 通知管理师/医生 + 升级高风险', async () => {
const sys = makeSystem();
const patient = await sys.patientService.create({ name: '小雅', age: 30, lmp: '2026-01-01' });
const result = await sys.redflagService.evaluate(patient.id, {
systolicBp: 165,
diastolicBp: 112,
symptoms: ['severe_headache'],
});
expect(result.triggered).toBe(true);
expect(result.hits.map((h) => h.ruleId)).toContain('RF-PREECLAMPSIA');
expect(result.patientAdvice).toContain('就医');
// 通知:孕妇 + 管理师 + 医生,均为紧急
const msgs = sys.notification.findByRecipient(patient.id);
const audiences = msgs.filter((m) => m.urgent).map((m) => m.audience);
expect(audiences).toEqual(expect.arrayContaining(['patient', 'case_manager', 'physician']));
// 个案升级为高风险
const c = await sys.caseflowService.getCaseByPatient(patient.id);
expect(c.riskLevel).toBe('high');
});
});
describe('S4 深夜疑问"我能吃这个吗"REQ-7', () => {
it('命中知识 → 带溯源作答;无依据 → 不超纲并建议就医', async () => {
const sys = makeSystem();
await sys.knowledgeService.create({
category: 'guideline',
title: '孕期饮食与血糖',
content: '控制精制碳水、少量多餐;水果适量并计入总量。',
keywords: ['饮食', '血糖', '水果', '能吃'],
source: '某权威指南',
authority: 'authoritative',
});
const grounded = await sys.aiService.ask('孕期能吃水果吗');
expect(grounded.grounded).toBe(true);
expect(grounded.citations.length).toBeGreaterThan(0);
expect(grounded.citations[0].source).toBe('某权威指南');
const ungrounded = await sys.aiService.ask('明天会下雨吗');
expect(ungrounded.grounded).toBe(false);
expect(ungrounded.citations).toHaveLength(0);
});
});
describe('S5 趋势预警/预测(REQ-3.5/4', () => {
it('纵向观测按孕周累积,构成趋势分析的数据基础', async () => {
const sys = makeSystem();
const patient = await sys.patientService.create({ name: '小雅', age: 30, lmp: '2026-01-01' });
await sys.observationService.record(patient.id, { indicator: 'fasting_glucose', value: 4.8 });
await sys.observationService.record(patient.id, { indicator: 'fasting_glucose', value: 5.0 });
const series = await sys.observationService.list(patient.id);
expect(series.length).toBe(2);
// 每条带孕周上下文,支撑后续时序趋势
expect(series.every((o) => typeof o.gestationalWeeks === 'number')).toBe(true);
});
// 趋势异常检测(REQ-3.5,V2)与预测模型(REQ-4,V3,需回顾性+前瞻性验证)超出 V1 范围
it.todo('S5 时序趋势异常检测(REQ-3.5)为 V2');
it.todo('S5 趋势预测模型(REQ-4)为 V3,需经验证');
});
describe('S6 提升坚持度、缓解焦虑(REQ-9', () => {
it('高风险孕妇运动提醒自动替换为休息(REQ-9.2 安全约束)', async () => {
const sys = makeSystem();
// 多胎 + 既往GDM → score 4 → 基线高风险
const patient = await sys.patientService.create({
name: '小雅',
age: 31,
lmp: '2026-01-01',
multipleGestation: true,
historyGdm: true,
});
expect(patient.initialRiskLevel).toBe('high');
const reminder = await sys.reminderService.dispatch({ patientId: patient.id, type: 'exercise' });
expect(reminder.effectiveType).toBe('rest');
expect(reminder.adjustedForRisk).toBe(true);
});
it('非高风险孕妇运动提醒保持运动', async () => {
const sys = makeSystem();
const patient = await sys.patientService.create({ name: '小雅', age: 28, lmp: '2026-01-01' });
const reminder = await sys.reminderService.dispatch({ patientId: patient.id, type: 'exercise' });
expect(reminder.effectiveType).toBe('exercise');
expect(reminder.adjustedForRisk).toBe(false);
});
// 游戏化(积分/徽章/小游戏,REQ-9.3/9.4)为 V2
it.todo('S6 游戏化激励(REQ-9.3/9.4)为 V2');
});
describe('S7 管理师 PC 端批量管理(REQ-13.3', () => {
it('多名孕妇入列,提供风险分层作为工作台排序依据', async () => {
const sys = makeSystem();
await sys.patientService.create({ name: '低风险', age: 28, lmp: '2026-01-01' });
await sys.patientService.create({ name: '中风险', age: 31, lmp: '2026-01-01', historyGdm: true });
await sys.patientService.create({
name: '高风险',
age: 36,
lmp: '2026-01-01',
multipleGestation: true,
historyPih: true,
});
const list = await sys.patientService.list();
expect(list.length).toBe(3);
// 每名孕妇均带初始风险分层(admin-web 工作列表据此按 高→中→低 排序)
const levels = list.map((p) => p.initialRiskLevel).sort();
expect(levels).toEqual(['high', 'low', 'medium']);
});
});
});
+13
View File
@@ -0,0 +1,13 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
const port = process.env.PORT ?? 3000;
await app.listen(port);
// eslint-disable-next-line no-console
console.log(`PCM backend listening on http://localhost:${port}/api`);
}
void bootstrap();
@@ -0,0 +1,24 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { AiService } from './ai.service';
import { Recommendation } from './recommendation';
import { QaAnswer } from '../knowledge/knowledge.types';
import { RequireCaps } from '../../common/auth/capabilities.decorator';
@Controller('ai')
export class AiController {
constructor(private readonly ai: AiService) {}
/** 聊天问答 */
@Get('ask')
@RequireCaps('knowledge:ask')
ask(@Query('q') q: string): Promise<QaAnswer> {
return this.ai.ask(q);
}
/** 个案决策建议(给管理师) */
@Get('patients/:patientId/recommendation')
@RequireCaps('alert:read')
recommend(@Param('patientId') patientId: string): Promise<Recommendation> {
return this.ai.recommendForPatient(patientId);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
import { AiController } from './ai.controller';
import { KnowledgeModule } from '../knowledge/knowledge.module';
import { AnalysisModule } from '../analysis/analysis.module';
import { CaseflowModule } from '../caseflow/caseflow.module';
@Module({
imports: [KnowledgeModule, AnalysisModule, CaseflowModule],
controllers: [AiController],
providers: [AiService],
exports: [AiService],
})
export class AiModule {}
@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { generateRecommendation, Recommendation } from './recommendation';
import { AnalysisService } from '../analysis/analysis.service';
import { CaseflowService } from '../caseflow/caseflow.service';
import { KnowledgeService } from '../knowledge/knowledge.service';
import { QaAnswer } from '../knowledge/knowledge.types';
/**
* AI 交互中枢(REQ-10)。
* - 问答:委托知识库 RAG(带溯源、不超纲);
* - 决策建议:基于个案当前风险与预警生成,高风险须人工确认(医生兜底)。
*/
@Injectable()
export class AiService {
constructor(
private readonly knowledge: KnowledgeService,
private readonly analysis: AnalysisService,
private readonly caseflow: CaseflowService,
) {}
/** 聊天问答(孕妇端主入口)(REQ-7.2/10.1 */
ask(question: string): Promise<QaAnswer> {
return this.knowledge.ask(question);
}
/** 为个案生成决策建议(REQ-10.2/10.3)。 */
async recommendForPatient(patientId: string): Promise<Recommendation> {
const c = await this.caseflow.getCaseByPatient(patientId);
const alerts = await this.analysis.listAlerts(patientId);
const openMessages = alerts
.filter((a) => a.status === 'open')
.flatMap((a) => a.messages);
return generateRecommendation({
riskLevel: c.riskLevel,
alertMessages: openMessages,
});
}
}
@@ -0,0 +1,20 @@
import { generateRecommendation } from './recommendation';
describe('generateRecommendationAI 决策建议)', () => {
it('低风险 → 无需人工确认', () => {
const r = generateRecommendation({ riskLevel: 'low', alertMessages: [] });
expect(r.requiresHumanConfirmation).toBe(false);
});
it('中风险 → 需人工确认且带依据', () => {
const r = generateRecommendation({ riskLevel: 'medium', alertMessages: ['血糖偏高'] });
expect(r.requiresHumanConfirmation).toBe(true);
expect(r.rationale).toContain('血糖偏高');
});
it('高风险 → 必须人工确认(医生兜底)', () => {
const r = generateRecommendation({ riskLevel: 'high', alertMessages: ['血压重度升高'] });
expect(r.requiresHumanConfirmation).toBe(true);
expect(r.actions.some((a) => a.includes('医生'))).toBe(true);
});
});
@@ -0,0 +1,47 @@
/**
* AI 决策建议生成(REQ-10.2/10.3/10.4)。纯逻辑,便于测试。
* 关键约束:高风险/急症的建议必须人工确认(requiresHumanConfirmation=true),
* AI 不自动执行临床决策(医生兜底)。每条建议带可解释依据。
*/
export type RiskLevel = 'low' | 'medium' | 'high';
export interface RecommendationInput {
riskLevel: RiskLevel;
/** 触发建议的预警说明(溯源) */
alertMessages: string[];
}
export interface Recommendation {
/** 建议动作(供管理师参考) */
actions: string[];
/** 必须人工确认后才可执行 */
requiresHumanConfirmation: boolean;
/** 可解释依据 */
rationale: string[];
}
export function generateRecommendation(input: RecommendationInput): Recommendation {
const rationale = [...input.alertMessages];
if (input.riskLevel === 'high') {
return {
actions: ['尽快联系孕妇核实', '安排医生评估', '考虑转诊/进一步检查'],
requiresHumanConfirmation: true,
rationale,
};
}
if (input.riskLevel === 'medium') {
return {
actions: ['加强监测频率', '提供针对性生活方式/饮食指导', '安排近期随访'],
requiresHumanConfirmation: true,
rationale,
};
}
return {
actions: ['维持常规监测与关怀'],
requiresHumanConfirmation: false,
rationale: rationale.length ? rationale : ['当前指标处于正常范围'],
};
}
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { Alert } from './alert.types';
/** 预警仓储抽象(内存实现,后续接入 DB)。 */
export abstract class AlertRepository {
abstract save(alert: Alert): Promise<Alert>;
abstract findByPatient(patientId: string): Promise<Alert[]>;
abstract findAll(): Promise<Alert[]>;
}
@Injectable()
export class InMemoryAlertRepository extends AlertRepository {
private readonly store: Alert[] = [];
async save(alert: Alert): Promise<Alert> {
this.store.push(alert);
return alert;
}
async findByPatient(patientId: string): Promise<Alert[]> {
return this.store.filter((a) => a.patientId === patientId);
}
async findAll(): Promise<Alert[]> {
return [...this.store];
}
}
@@ -0,0 +1,20 @@
import { RiskLevel } from './rule-engine';
import { IndicatorType } from './indicator';
export type AlertStatus = 'open' | 'acknowledged' | 'resolved';
/** 预警事件(REQ-3.3)。可解释、可追溯到观测值与规则(NFR-3)。 */
export interface Alert {
id: string;
patientId: string;
observationId: string;
indicator: IndicatorType;
value: number;
level: RiskLevel;
/** 命中规则 ID 列表(溯源) */
ruleIds: string[];
/** 人类可读说明 */
messages: string[];
status: AlertStatus;
createdAt: string;
}
@@ -0,0 +1,15 @@
import { Controller, Get, Param } from '@nestjs/common';
import { AnalysisService } from './analysis.service';
import { Alert } from './alert.types';
import { RequireCaps } from '../../common/auth/capabilities.decorator';
@Controller('patients/:patientId/alerts')
export class AnalysisController {
constructor(private readonly analysisService: AnalysisService) {}
@Get()
@RequireCaps('alert:read')
list(@Param('patientId') patientId: string): Promise<Alert[]> {
return this.analysisService.listAlerts(patientId);
}
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { Pool } from 'pg';
import { AnalysisService } from './analysis.service';
import { AnalysisController } from './analysis.controller';
import { AlertRepository, InMemoryAlertRepository } from './alert.repository';
import { PostgresAlertRepository } from './postgres-alert.repository';
import { PG_POOL } from '../../common/db/db.tokens';
import { createSealerFromEnv } from '../../common/crypto/field-sealer';
@Module({
controllers: [AnalysisController],
providers: [
AnalysisService,
{
provide: AlertRepository,
useFactory: (pool: Pool | null): AlertRepository =>
pool ? new PostgresAlertRepository(pool, createSealerFromEnv()) : new InMemoryAlertRepository(),
inject: [PG_POOL],
},
],
exports: [AnalysisService],
})
export class AnalysisModule {}
@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { AlertRepository } from './alert.repository';
import { Alert } from './alert.types';
import { evaluateIndicator, RiskLevel } from './rule-engine';
import { IndicatorType } from './indicator';
export interface AnalysisInput {
patientId: string;
observationId: string;
indicator: IndicatorType;
value: number;
gestationalWeeks: number;
}
export interface AnalysisResult {
level: RiskLevel;
alert: Alert | null;
}
/**
* 分析服务(REQ-3)。
* 对单个观测值运行规则引擎;命中 medium/high 时生成可追溯预警。
* 高风险的人工兜底与处置由 caseflow(T-5)/aiT-7.3)负责,此处只产出预警。
*/
@Injectable()
export class AnalysisService {
constructor(private readonly alertRepo: AlertRepository) {}
async evaluateObservation(input: AnalysisInput): Promise<AnalysisResult> {
const result = evaluateIndicator(input.indicator, input.value, {
gestationalWeeks: input.gestationalWeeks,
});
if (result.level === 'low' || result.hits.length === 0) {
return { level: result.level, alert: null };
}
const alert: Alert = {
id: randomUUID(),
patientId: input.patientId,
observationId: input.observationId,
indicator: input.indicator,
value: input.value,
level: result.level,
ruleIds: result.hits.map((h) => h.ruleId),
messages: result.hits.map((h) => h.message),
status: 'open',
createdAt: new Date().toISOString(),
};
await this.alertRepo.save(alert);
return { level: result.level, alert };
}
listAlerts(patientId: string): Promise<Alert[]> {
return this.alertRepo.findByPatient(patientId);
}
listAllAlerts(): Promise<Alert[]> {
return this.alertRepo.findAll();
}
}
@@ -0,0 +1,39 @@
/**
* 指标目录(REQ-1/REQ-3)。
* 含单位与"生理合理范围"(用于数据质控 T-3.4)。
* MVP 聚焦 GDM(血糖)与妊娠期高血压(血压)。
*/
export type IndicatorType =
| 'fasting_glucose' // 空腹血糖
| 'ogtt_1h' // OGTT 1小时
| 'ogtt_2h' // OGTT 2小时
| 'postprandial_glucose' // 餐后血糖
| 'systolic_bp' // 收缩压
| 'diastolic_bp' // 舒张压
| 'weight' // 体重
| 'heart_rate'; // 心率
export interface IndicatorMeta {
type: IndicatorType;
label: string;
unit: string;
/** 生理合理范围(超出视为不可信,用于质控) */
plausibleMin: number;
plausibleMax: number;
}
export const INDICATORS: Record<IndicatorType, IndicatorMeta> = {
fasting_glucose: { type: 'fasting_glucose', label: '空腹血糖', unit: 'mmol/L', plausibleMin: 1, plausibleMax: 40 },
ogtt_1h: { type: 'ogtt_1h', label: 'OGTT 1小时血糖', unit: 'mmol/L', plausibleMin: 1, plausibleMax: 40 },
ogtt_2h: { type: 'ogtt_2h', label: 'OGTT 2小时血糖', unit: 'mmol/L', plausibleMin: 1, plausibleMax: 40 },
postprandial_glucose: { type: 'postprandial_glucose', label: '餐后血糖', unit: 'mmol/L', plausibleMin: 1, plausibleMax: 40 },
systolic_bp: { type: 'systolic_bp', label: '收缩压', unit: 'mmHg', plausibleMin: 50, plausibleMax: 300 },
diastolic_bp: { type: 'diastolic_bp', label: '舒张压', unit: 'mmHg', plausibleMin: 30, plausibleMax: 200 },
weight: { type: 'weight', label: '体重', unit: 'kg', plausibleMin: 30, plausibleMax: 200 },
heart_rate: { type: 'heart_rate', label: '心率', unit: 'bpm', plausibleMin: 30, plausibleMax: 250 },
};
export function isIndicatorType(value: string): value is IndicatorType {
return Object.prototype.hasOwnProperty.call(INDICATORS, value);
}

Some files were not shown because too many files have changed in this diff Show More