Initial commit: HealthCarePregnant project documentation and platform
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# PCM 部署编排环境变量示例(复制为 .env;勿提交真实密钥)
|
||||
|
||||
# 后端鉴权令牌签名密钥(生产必须为强随机值)
|
||||
AUTH_SECRET=change-me-to-a-strong-random-secret
|
||||
|
||||
# 字段级加密密钥(AES-256-GCM,32 字节 hex64/base64;生产经 KMS 注入)
|
||||
# 生成:node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
FIELD_ENCRYPTION_KEY=
|
||||
|
||||
# PostgreSQL(db 服务)
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DB=pcm
|
||||
|
||||
# 各服务对外端口(可按环境调整)
|
||||
BACKEND_PORT=3000
|
||||
PATIENT_PORT=8081
|
||||
ADMIN_PORT=8082
|
||||
FAMILY_PORT=8083
|
||||
@@ -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
|
||||
@@ -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 移动 Web,AI 对话优先)
|
||||
- `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(令牌校验)+ CapabilitiesGuard(RBAC 能力授权,越权 403 并审计)。
|
||||
- 孕产+胎儿数据按最敏感个人信息处理(加密、最小化、分级访问、审计)。
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.git
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -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: '^_' }],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.DS_Store
|
||||
.vite
|
||||
@@ -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
|
||||
@@ -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`。
|
||||
@@ -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>
|
||||
@@ -0,0 +1,20 @@
|
||||
# 医护/运营端 nginx:SPA 客户端路由回退 + /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;
|
||||
}
|
||||
}
|
||||
Generated
+3570
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`),
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) ?? '';
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.git
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -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-GCM,32 字节;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,不得提交仓库
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
coverage/
|
||||
.DS_Store
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+9411
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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:未配置返回 null;hex64 解析为 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('未配置密钥 → 明文 JSON(dev),可还原', () => {
|
||||
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/规则/说明等敏感内容加密入 enc;level/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);`);
|
||||
|
||||
// 照护计划:目标/干预等临床内容加密入 enc;case/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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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('generateRecommendation(AI 决策建议)', () => {
|
||||
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)/ai(T-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(持久化);
|
||||
* - 否则 → InMemoryAuthRepository(dev/测试)。
|
||||
*/
|
||||
@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
Reference in New Issue
Block a user