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
+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);
}
@@ -0,0 +1,85 @@
import { Pool } from 'pg';
import { AlertRepository } from './alert.repository';
import { Alert } from './alert.types';
import { IndicatorType } from './indicator';
import { RiskLevel } from './rule-engine';
import { FieldSealer } from '../../common/crypto/field-sealer';
interface SensitiveAlert {
value: number;
ruleIds: string[];
messages: string[];
}
interface AlertRow {
id: string;
patient_id: string;
observation_id: string | null;
indicator: string;
level: string;
status: string;
enc: string;
created_at: Date;
}
/** PostgreSQL 预警仓储(T-1.2)。value/规则/说明加密入 enc。 */
export class PostgresAlertRepository extends AlertRepository {
constructor(
private readonly pool: Pool,
private readonly sealer: FieldSealer,
) {
super();
}
async save(alert: Alert): Promise<Alert> {
const sensitive: SensitiveAlert = {
value: alert.value,
ruleIds: alert.ruleIds,
messages: alert.messages,
};
await this.pool.query(
`INSERT INTO alerts (id, patient_id, observation_id, indicator, level, status, enc, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (id) DO NOTHING`,
[
alert.id,
alert.patientId,
alert.observationId,
alert.indicator,
alert.level,
alert.status,
this.sealer.seal(sensitive),
alert.createdAt,
],
);
return alert;
}
async findByPatient(patientId: string): Promise<Alert[]> {
const res = await this.pool.query<AlertRow>(
'SELECT * FROM alerts WHERE patient_id = $1 ORDER BY created_at',
[patientId],
);
return res.rows.map((r) => this.toAlert(r));
}
async findAll(): Promise<Alert[]> {
const res = await this.pool.query<AlertRow>('SELECT * FROM alerts ORDER BY created_at DESC');
return res.rows.map((r) => this.toAlert(r));
}
private toAlert(row: AlertRow): Alert {
const sensitive = this.sealer.open<SensitiveAlert>(row.enc);
return {
id: row.id,
patientId: row.patient_id,
observationId: row.observation_id ?? '',
indicator: row.indicator as IndicatorType,
value: sensitive.value,
level: row.level as RiskLevel,
ruleIds: sensitive.ruleIds,
messages: sensitive.messages,
status: row.status as Alert['status'],
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
};
}
}
@@ -0,0 +1,40 @@
import { evaluateIndicator } from './rule-engine';
const ctx = { gestationalWeeks: 26 };
describe('rule-engine(规则引擎)', () => {
it('正常空腹血糖 → low 无命中', () => {
const r = evaluateIndicator('fasting_glucose', 4.8, ctx);
expect(r.level).toBe('low');
expect(r.hits).toHaveLength(0);
});
it('空腹血糖达 GDM 界值 → medium', () => {
const r = evaluateIndicator('fasting_glucose', 5.3, ctx);
expect(r.level).toBe('medium');
expect(r.hits[0].ruleId).toBe('FG-1');
});
it('空腹血糖明显升高 → high(取最严重档)', () => {
const r = evaluateIndicator('fasting_glucose', 7.5, ctx);
expect(r.level).toBe('high');
});
it('收缩压 140 → medium', () => {
expect(evaluateIndicator('systolic_bp', 142, ctx).level).toBe('medium');
});
it('收缩压 165 → high', () => {
expect(evaluateIndicator('systolic_bp', 165, ctx).level).toBe('high');
});
it('舒张压正常 → low', () => {
expect(evaluateIndicator('diastolic_bp', 75, ctx).level).toBe('low');
});
it('命中信息可解释(含 ruleId 与 message', () => {
const r = evaluateIndicator('diastolic_bp', 115, ctx);
expect(r.hits[0].ruleId).toBe('DBP-1');
expect(r.hits[0].message).toContain('舒张压');
});
});
@@ -0,0 +1,130 @@
/**
* 规则引擎(REQ-3.1/3.2/3.4)。
* 单指标阈值规则 → 风险等级 + 可解释信息。按孕周适配(REQ-3 约束)。
*
* ⚠️ 阈值为通用临床常识占位(妊娠期糖尿病/高血压通行诊断界值),
* 最终阈值与孕周分段须由专业医生确认(C-2 / A-1)。规则表可配置扩展(NFR-7)。
*
* 红旗急症组合规则属 T-4.3,不在本单指标引擎内。
*/
export type RiskLevel = 'low' | 'medium' | 'high';
export type IndicatorType = string;
export interface RuleContext {
gestationalWeeks: number;
}
export interface RuleHit {
ruleId: string;
level: RiskLevel;
message: string;
}
export interface ThresholdRule {
id: string;
indicator: IndicatorType;
/** 适用条件(如孕周窗口);缺省表示始终适用 */
appliesTo?: (ctx: RuleContext) => boolean;
/** 评估单条规则,命中返回 RuleHit,否则 null */
evaluate: (value: number, ctx: RuleContext) => RuleHit | null;
}
/** 升序阈值辅助:从高到低匹配,命中最严重的一档 */
function gradedRule(
id: string,
indicator: IndicatorType,
grades: Array<{ min: number; level: RiskLevel; message: string }>,
): ThresholdRule {
const ordered = [...grades].sort((a, b) => b.min - a.min);
return {
id,
indicator,
evaluate(value): RuleHit | null {
for (const g of ordered) {
if (value >= g.min) {
return { ruleId: id, level: g.level, message: g.message };
}
}
return null;
},
};
}
/** MVP 规则表(GDM + 妊娠期高血压) */
export const DEFAULT_RULES: readonly ThresholdRule[] = [
gradedRule('FG-1', 'fasting_glucose', [
{ min: 7.0, level: 'high', message: '空腹血糖明显升高(≥7.0 mmol/L),需尽快评估' },
{ min: 5.1, level: 'medium', message: '空腹血糖达妊娠期糖尿病界值(≥5.1 mmol/L' },
]),
gradedRule('OGTT1-1', 'ogtt_1h', [
{ min: 10.0, level: 'medium', message: 'OGTT 1小时血糖达 GDM 界值(≥10.0 mmol/L' },
]),
gradedRule('OGTT2-1', 'ogtt_2h', [
{ min: 8.5, level: 'medium', message: 'OGTT 2小时血糖达 GDM 界值(≥8.5 mmol/L' },
]),
gradedRule('SBP-1', 'systolic_bp', [
{ min: 160, level: 'high', message: '收缩压重度升高(≥160 mmHg' },
{ min: 140, level: 'medium', message: '收缩压升高(≥140 mmHg),妊娠期高血压可能' },
]),
gradedRule('DBP-1', 'diastolic_bp', [
{ min: 110, level: 'high', message: '舒张压重度升高(≥110 mmHg' },
{ min: 90, level: 'medium', message: '舒张压升高(≥90 mmHg),妊娠期高血压可能' },
]),
gradedRule('PPG-1', 'postprandial_glucose', [
{ min: 11.1, level: 'high', message: '餐后血糖明显升高(≥11.1 mmol/L),需尽快评估' },
{ min: 6.7, level: 'medium', message: '餐后2小时血糖达妊娠期糖耐量异常界值(≥6.7 mmol/L' },
]),
{
id: 'WT-1',
indicator: 'weight',
appliesTo: (ctx) => ctx.gestationalWeeks >= 28,
evaluate: (val) => val >= 90 ? { ruleId: 'WT-1', level: 'medium', message: '孕晚期体重偏高(≥90.0 kg),需注意控制增重速度' } : null
},
{
id: 'HR-1',
indicator: 'heart_rate',
evaluate: (val) => {
if (val >= 110) {
return { ruleId: 'HR-1', level: 'medium', message: '心率偏快(≥110 bpm),请静息复测' };
}
if (val <= 50) {
return { ruleId: 'HR-1', level: 'medium', message: '心率偏慢(≤50 bpm),警惕低血压或房室阻滞可能' };
}
return null;
}
}
];
const LEVEL_ORDER: Record<RiskLevel, number> = { low: 0, medium: 1, high: 2 };
export interface EvaluationResult {
/** 综合风险等级(命中规则中的最高档;无命中为 low) */
level: RiskLevel;
hits: RuleHit[];
}
/**
* 评估单个指标值。
*/
export function evaluateIndicator(
indicator: IndicatorType,
value: number,
ctx: RuleContext,
rules: readonly ThresholdRule[] = DEFAULT_RULES,
): EvaluationResult {
const hits: RuleHit[] = [];
for (const rule of rules) {
if (rule.indicator !== indicator) continue;
if (rule.appliesTo && !rule.appliesTo(ctx)) continue;
const hit = rule.evaluate(value, ctx);
if (hit) hits.push(hit);
}
const level = hits.reduce<RiskLevel>(
(acc, h) => (LEVEL_ORDER[h.level] > LEVEL_ORDER[acc] ? h.level : acc),
'low',
);
return { level, hits };
}
@@ -0,0 +1,34 @@
import { AuditController } from './audit.controller';
import { AuditService } from './audit.service';
describe('AuditController(审计查询 T-8.4', () => {
let service: AuditService;
let controller: AuditController;
beforeEach(() => {
service = new AuditService();
controller = new AuditController(service);
service.record('u1', 'auth:login');
service.record('u2', 'auth:register', 'patient');
service.record('u1', 'case:advance', 'case-1');
});
it('无过滤 → 返回全部,按时间倒序', async () => {
const list = await controller.list();
expect(list).toHaveLength(3);
// 最新记录在前
expect(list[0].action).toBe('case:advance');
});
it('按操作者过滤', async () => {
const list = await controller.list('u1');
expect(list).toHaveLength(2);
expect(list.every((e) => e.actorId === 'u1')).toBe(true);
});
it('按动作过滤', async () => {
const list = await controller.list(undefined, 'auth:login');
expect(list).toHaveLength(1);
expect(list[0].action).toBe('auth:login');
});
});
@@ -0,0 +1,24 @@
import { Controller, Get, Query } from '@nestjs/common';
import { AuditEntry, AuditService } from './audit.service';
import { RequireCaps } from '../../common/auth/capabilities.decorator';
/**
* 审计查询(NFR-1/9 · T-1.3,运营/管理端 T-8.4)。
* 支持按操作者/动作检索审计记录。
*
* 鉴权:受全局 JwtAuthGuard + CapabilitiesGuard 保护,需 `audit:read`(仅 admin)。
*/
@Controller('audit')
export class AuditController {
constructor(private readonly audit: AuditService) {}
@Get()
@RequireCaps('audit:read')
async list(
@Query('actorId') actorId?: string,
@Query('action') action?: string,
): Promise<AuditEntry[]> {
const entries = await this.audit.query({ actorId, action });
return entries.slice().sort((a, b) => (a.at < b.at ? 1 : -1));
}
}
@@ -0,0 +1,24 @@
import { Global, Module } from '@nestjs/common';
import { Pool } from 'pg';
import { AuditService } from './audit.service';
import { AuditController } from './audit.controller';
import { InMemoryAuditRepository, PostgresAuditRepository } from './audit.repository';
import { PG_POOL } from '../../common/db/db.tokens';
/**
* 审计仓储按是否启用 PostgreSQL 自动切换(持久化 vs 内存)。
*/
@Global()
@Module({
controllers: [AuditController],
providers: [
{
provide: AuditService,
useFactory: (pool: Pool | null): AuditService =>
new AuditService(pool ? new PostgresAuditRepository(pool) : new InMemoryAuditRepository()),
inject: [PG_POOL],
},
],
exports: [AuditService],
})
export class AuditModule {}
@@ -0,0 +1,72 @@
import { Pool } from 'pg';
import { AuditEntry, AuditFilter } from './audit.types';
/** 审计仓储抽象。内存(dev/测试)与 PostgreSQL(持久化)两种实现。 */
export abstract class AuditRepository {
abstract append(entry: AuditEntry): void | Promise<void>;
abstract query(filter?: AuditFilter): Promise<AuditEntry[]>;
}
export class InMemoryAuditRepository extends AuditRepository {
private readonly entries: AuditEntry[] = [];
append(entry: AuditEntry): void {
this.entries.push(entry);
}
async query(filter?: AuditFilter): Promise<AuditEntry[]> {
return this.entries.filter(
(e) =>
(!filter?.actorId || e.actorId === filter.actorId) &&
(!filter?.action || e.action === filter.action),
);
}
}
interface AuditRow {
id: string;
actor_id: string;
action: string;
target: string | null;
at: Date;
}
/** PostgreSQL 审计仓储(T-1.3/NFR-9 持久化、防丢失)。 */
export class PostgresAuditRepository extends AuditRepository {
constructor(private readonly pool: Pool) {
super();
}
async append(entry: AuditEntry): Promise<void> {
await this.pool.query(
`INSERT INTO audit_log (id, actor_id, action, target, at) VALUES ($1, $2, $3, $4, $5)`,
[entry.id, entry.actorId, entry.action, entry.target ?? null, entry.at],
);
}
async query(filter?: AuditFilter): Promise<AuditEntry[]> {
const conds: string[] = [];
const params: unknown[] = [];
if (filter?.actorId) {
params.push(filter.actorId);
conds.push(`actor_id = $${params.length}`);
}
if (filter?.action) {
params.push(filter.action);
conds.push(`action = $${params.length}`);
}
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
const res = await this.pool.query<AuditRow>(`SELECT * FROM audit_log ${where}`, params);
return res.rows.map(toEntry);
}
}
function toEntry(row: AuditRow): AuditEntry {
return {
id: row.id,
actorId: row.actor_id,
action: row.action,
target: row.target ?? undefined,
at: row.at instanceof Date ? row.at.toISOString() : String(row.at),
};
}
@@ -0,0 +1,45 @@
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { AuditEntry, AuditFilter } from './audit.types';
import { AuditRepository, InMemoryAuditRepository } from './audit.repository';
// 向后兼容:保留从本模块导出 AuditEntry
export type { AuditEntry } from './audit.types';
/**
* 操作审计(T-1.3 / NFR-1/9)。
* 关键操作(数据访问、处置、配置、越权)留痕,支持合规与责任界定。
* 仓储可切换:未配置 DB 时为内存;配置 DATABASE_URL 时为 PostgreSQL(持久化、防丢失)。
*/
@Injectable()
export class AuditService {
private readonly logger = new Logger('Audit');
private readonly repo: AuditRepository;
constructor(repo?: AuditRepository) {
this.repo = repo ?? new InMemoryAuditRepository();
}
/**
* 记录审计。返回构造的条目(同步);实际写入为 fire-and-forget
* 写入失败仅记录日志,不阻塞主流程(审计不应影响业务可用性)。
*/
record(actorId: string, action: string, target?: string): AuditEntry {
const entry: AuditEntry = {
id: randomUUID(),
actorId,
action,
target,
at: new Date().toISOString(),
};
void Promise.resolve(this.repo.append(entry)).catch((e) =>
this.logger.error(`审计写入失败:${String(e)}`),
);
this.logger.log(`${actorId} ${action}${target ? ' ' + target : ''}`);
return entry;
}
query(filter?: AuditFilter): Promise<AuditEntry[]> {
return this.repo.query(filter);
}
}
@@ -0,0 +1,12 @@
export interface AuditEntry {
id: string;
actorId: string;
action: string;
target?: string;
at: string;
}
export interface AuditFilter {
actorId?: string;
action?: string;
}
@@ -0,0 +1,20 @@
import { Body, Controller, Post } from '@nestjs/common';
import { AuthService, RegisterInput } from './auth.service';
import { AuthResult } from './auth.types';
import { Public } from '../../common/auth/public.decorator';
@Public()
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('register')
register(@Body() body: RegisterInput): Promise<AuthResult> {
return this.auth.register(body);
}
@Post('login')
login(@Body() body: { username: string; password: string }): Promise<AuthResult> {
return this.auth.login(body.username, body.password);
}
}
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { Pool } from 'pg';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { AuthRepository, InMemoryAuthRepository } from './auth.repository';
import { PostgresAuthRepository } from './postgres-auth.repository';
import { PG_POOL } from '../../common/db/db.tokens';
/**
* 仓储按是否启用 PostgreSQL 自动切换:
* - PG_POOL 存在 → PostgresAuthRepository(持久化);
* - 否则 → InMemoryAuthRepositorydev/测试)。
*/
@Module({
controllers: [AuthController],
providers: [
AuthService,
{
provide: AuthRepository,
useFactory: (pool: Pool | null): AuthRepository =>
pool ? new PostgresAuthRepository(pool) : new InMemoryAuthRepository(),
inject: [PG_POOL],
},
],
exports: [AuthService],
})
export class AuthModule {}
@@ -0,0 +1,100 @@
import { Injectable } from '@nestjs/common';
import { User } from './auth.types';
import { hashPassword } from './password';
export abstract class AuthRepository {
abstract save(user: User): Promise<User>;
abstract findByUsername(username: string): Promise<User | null>;
abstract findById(id: string): Promise<User | null>;
}
@Injectable()
export class InMemoryAuthRepository extends AuthRepository {
private readonly users = new Map<string, User>();
constructor() {
super();
this.seedDemoUsers();
}
private seedDemoUsers() {
const demoUsers: User[] = [
{
id: 'demo-pregnant-01',
username: 'test_pregnant_01',
passwordHash: hashPassword('12345678'),
role: 'patient',
consentSigned: true,
createdAt: new Date().toISOString(),
},
{
id: 'demo-pregnant-02',
username: 'test_pregnant_02',
passwordHash: hashPassword('12345678'),
role: 'patient',
consentSigned: true,
createdAt: new Date().toISOString(),
},
{
id: 'demo-family-01',
username: 'test_family_01',
passwordHash: hashPassword('12345678'),
role: 'family',
consentSigned: true,
createdAt: new Date().toISOString(),
},
{
id: 'demo-manager-01',
username: 'test_manager_01',
passwordHash: hashPassword('12345678'),
role: 'case_manager',
consentSigned: true,
createdAt: new Date().toISOString(),
},
{
id: 'demo-doctor-01',
username: 'test_doctor_01',
passwordHash: hashPassword('12345678'),
role: 'physician',
consentSigned: true,
createdAt: new Date().toISOString(),
},
{
id: 'demo-operator-01',
username: 'test_operator_01',
passwordHash: hashPassword('12345678'),
role: 'operator',
consentSigned: true,
createdAt: new Date().toISOString(),
},
{
id: 'demo-admin-01',
username: 'test_admin_01',
passwordHash: hashPassword('12345678'),
role: 'admin',
consentSigned: true,
createdAt: new Date().toISOString(),
},
];
for (const user of demoUsers) {
this.users.set(user.id, user);
}
}
async save(user: User): Promise<User> {
this.users.set(user.id, user);
return user;
}
async findByUsername(username: string): Promise<User | null> {
for (const u of this.users.values()) {
if (u.username === username) return u;
}
return null;
}
async findById(id: string): Promise<User | null> {
return this.users.get(id) ?? null;
}
}

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