feat: initialize ERP base platform (extracted from HMS)

- Stripped 11 business crates (health, ai, dialysis, plugins)
- Cleaned AppState, AppConfig, main.rs from business coupling
- Reduced migrations from 169 to 53 (base-only)
- Removed health_provider trait from erp-core
- Removed business integration tests
- Removed gateway rate limiting middleware
- Base capabilities: auth, RBAC, JWT, config, workflow, message, plugin, audit, crypto, RLS, multi-tenant

Cargo check: OK
Cargo test: OK
This commit is contained in:
iven
2026-05-31 20:35:57 +08:00
commit 59856ac2fc
639 changed files with 124710 additions and 0 deletions

109
.gitignore vendored Normal file
View File

@@ -0,0 +1,109 @@
# Rust
/target/
**/*.rs.bk
# Node
node_modules/
dist/
# Tauri
apps/desktop/src-tauri/target/
# IDE
.vscode/
.idea/
*.swp
# Environment
.env
*.env.local
# OS
.DS_Store
Thumbs.db
# Docker data
docker/postgres_data/
docker/redis_data/
# Test artifacts
.test_token
test-results/
# Build outputs
apps/miniprogram/dist-h5/
# Runtime uploads
uploads/
# Temp logs
_server_out.txt
*.heapsnapshot
perf-trace-*.json
docs/debug-*.png
# Development env
.env.development
docker/docker-compose.override.yml
.agents/skills/
.claude/skills/
.kiro/skills/
.trae/skills/
.windsurf/skills/
skills/
# Logs
.logs/
*.log
# Playwright reports
**/playwright-report/
# Plans
plans/
# MCP config
.mcp.json
# Superpowers temp
.superpowers/brainstorm/
# Test temp files
.test_token*
chi_sim.traineddata
# Local settings
.claude/settings.local.json
tools/
# Temp/debug files
_temp/
tmp/
screenshots/
server-log.txt
snapshot_*.txt
_*.txt
_server_*.txt
tmp_*.txt
direct_*.txt
server_*.txt
server_combined.txt
out.txt
_wx_login.json
.claude/settings.json
# Trace/debug JSON
trace-*.json
# Graphify knowledge graph (regenerated locally)
graphify-out/
# Native miniprogram (separate project)
apps/mp-native/
# Misc untracked
err.txt
uploads/g:/hms/.superpowers/
.claude/skills/design-handoff/node_modules/
.design/config.yml
.superpowers/

535
CLAUDE.md Normal file
View File

@@ -0,0 +1,535 @@
@wiki/index.md
整个项目对话都使用中文进行,包括文档、代码注释、事件名称等。
# HMS 健康管理平台 — 协作与实现规则
> **Health Management System (HMS)** — 面向体检中心/医疗机构的综合健康管理平台。从 ERP 底座分叉独立,继承身份权限/工作流/消息/配置等基础能力,`erp-health` 原生模块承载医疗业务。
> **当前阶段: erp-health 模块开发。** 设计规格已确认,开始实施。
## 1. 项目定位
### 1.1 这是什么
一个 **健康管理 + ERP 基础设施** 架构的医疗 SaaS 平台:
- **医疗核心** — 患者管理、健康数据、预约排班、随访管理、咨询管理(原生 Rust 模块 erp-health
- **基础底座** — 身份权限、工作流引擎、消息中心、系统配置(继承自 ERP
- **多租户 + 私有化** — 默认 SaaS 共享数据库隔离,支持独立 schema 私有部署
- **Web 优先** — 浏览器 SPA 是 PC 管理后台主力,小程序(患者端/医护端)独立开发
### 1.2 决策原则
**任何改动都要问:这对健康管理平台的医疗业务和可扩展性有帮助吗?**
- ✅ 完善模块接口和 trait 定义 → 最高优先
- ✅ 确保多租户隔离的正确性 → 最高优先
- ✅ 按计划推进 Phase 交付物 → 高优先
- ✅ 清晰的模块边界和事件契约 → 高优先
- ❌ 跳过 Phase 顺序提前实现远期功能 → 禁止
- ❌ 在模块间创建直接耦合 → 永远不做
- ❌ 硬编码租户 ID 或绕过多租户中间件 → 永远不做
- ❌ 过度设计未来才需要的能力 → 永远不做
### 1.3 架构铁律
| 约束 | 原因 |
|------|------|
| 模块间只通过事件总线和 trait 通信 | 保证模块可独立拆分为微服务 |
| 所有数据表必须含 `tenant_id` | 多租户是核心能力,不可事后补 |
| 使用 UUID v7 作为主键 | 时间排序 + 唯一性,分布式友好 |
| 软删除,不硬删除 | ERP 数据不可丢失,审计追溯需要 |
| 所有 API 使用 `/api/v1/` 前缀 | 版本化是 SaaS 产品的基本要求 |
---
## 2. 工作风格
### 2.1 按计划推进
- **严格按 Phase 顺序执行** — Phase 2 依赖 Phase 1 的基础设施
- **每个 Task 完成后立即提交** — 不积压,保持可追溯
- **先测试后实现** — TDD 流程:写失败测试 → 实现 → 通过 → 提交
### 2.2 分步编写文档(强制)
编写计划、设计文档、实施报告等长文档时,**必须分步编写**,禁止一次性输出全文:
1. **先写大纲** — 确认文档结构和章节划分
2. **逐章编写** — 每次只写 1-2 个章节,写完确认后继续下一章
3. **最终整合** — 所有章节完成后合并为完整文档
**原因:** 上下文过长会导致输出截断或卡死。分步编写保证每步都能完整输出,且用户可以中途调整方向。
**适用范围:** 超过 200 行的文档、实施计划、设计规格、技术报告等。简短的 bugfix 说明、单页 wiki 更新不受此限制。
### 2.3 讨论记录
每次发散式讨论brainstorming、方案探索、需求梳理、技术选型等**必须建立独立文档**
- **存放位置:** `docs/discussions/YYYY-MM-DD-{主题简称}.md`
- **文档格式:**
```markdown
# {讨论主题}
> 日期: YYYY-MM-DD | 参与者: ...
## 背景
为什么会有这次讨论
## 讨论要点
- 要点 1
- 要点 2
## 结论 / 待定
达成的共识或遗留问题
```
- **时机:** 讨论结束后立即创建,不要积压。如果讨论横跨多个主题,拆分为多份文档。
- **用途:** 作为后续实施的输入和决策追溯的依据,避免"之前讨论过但忘了结论"。
### 2.4 模块化思维
开发任何功能时先问:
1. **它属于哪个模块?** — 不确定就放到 `erp-core` 共享层
2. **它的接口是什么?** — 先定义 trait再实现
3. **它需要发什么事件?** — 跨模块通知必须走事件总线
4. **其他模块怎么发现它?** — 通过 `ErpModule` trait 注册
### 2.5 闭环工作法(强制)
每次改动**必须**按顺序完成以下步骤,不允许跳过:
0. **阅读 Wiki强制起点** — 收到任何任务后,**先读 wiki 再动手**
- 读取 `wiki/index.md` 了解项目全貌和当前进度
- 根据任务涉及的范围,读取相关 wiki 页面(`wiki/infrastructure.md`、`wiki/testing.md`、`wiki/wasm-plugin.md` 等)
- wiki 中包含实际的环境配置(数据库连接、端口、登录凭据、启动方式),不看 wiki 就无法正确验证
- **违反此步骤 = 盲目工作,浪费时间去猜环境配置,产出不可信**
1. **现状确认(强制)** — 动手之前,先检查代码里已经有什么:
- 用 Grep/Glob/Read 工具搜索相关文件,确认哪些能力已存在
- 明确列出"已有"和"缺失",不允许凭印象断言缺失
- 如果不确定现有实现状态,停下来问用户,不要编造
- 违反此步骤 = 所有后续工作可能脱离实际,白费力气
2. **理解需求** — 确认改动的目标模块和影响范围
3. **最小实现** — 只改必要的代码,保持模块边界
4. **验证通过** — 必须全部通过才可继续:
- `cargo check` — 编译无错误
- `cargo test --workspace` — 所有测试通过(有相关测试时)
- 功能验证 — 启动后端 + 前端服务,在浏览器中实际操作验证改动生效(涉及 API 或 UI 时)
- `pnpm build` — 前端生产构建通过(涉及前端时)
5. **提交 + 文档 + 推送(三合一,强制)** — 验证通过后按顺序执行:
- a. 按 §5 规范提交代码
- b. 检查本次变更是否触发 wiki 更新(见下方 wiki 更新触发条件),触发则更新后单独 `docs(wiki)` 提交
- c. `git push` 立即推送,不允许"等一下再推"
- **禁止连续 5 个非 docs 提交而不更新 wiki 关键数字**
#### wiki 更新触发条件(步骤 5b 的判定标准)
以下任一条件满足时,**必须**更新 wiki 后才能继续下一任务:
- **fix 提交** → `wiki/index.md` 症状导航新增条目或标记"已修复"
- **feat 提交(新功能)** → `wiki/index.md` 关键数字更新 + 对应模块 wiki 页更新(实体数/路由数/端点数等)
- **数据库迁移变化** → 关键数字中的迁移数/表数更新
- **API 路由变化** → 路由数更新
- **测试数量变化** → 测试数/断言数更新
- **连续 5 个代码提交** → 强制做一次 wiki/index.md 关键数字全文校正(对比代码实际数量)
**铁律:**
- **步骤 0 阅读 Wiki 是绝对起点** — 不读 wiki 就开干 = 连环境配置都不知道,所有验证步骤都是空谈。
- **步骤 1 现状确认是强制起点** — 不检查就开干 = 脱离实际,所有产出不可信。
- **步骤 4 功能验证必须实际操作** — 只看编译通过不算验证,必须启动服务、在浏览器中确认功能正常。
- **步骤 5 三合一是强制流程** — 提交后必须检查 wiki、必须推送缺一不可。
- **每次新会话开始时,先检查是否有未推送的提交并立即推送**。
### 2.6 Feature DoD — 功能完成定义(强制)
> 历史数据显示 24% 的提交是 fix根因是缺少统一的完成标准。
> 每个功能标记"完成"前,**必须**逐项检查以下清单,不允许跳过。
#### 后端
- [ ] Entity 包含所有标准字段(`id`/`tenant_id`/`created_at`/`updated_at`/`created_by`/`updated_by`/`deleted_at`/`version`
- [ ] Handler 添加 `require_permission` 权限守卫
- [ ] 权限码已写入 seed 迁移(每个实体 `.list` + `.manage`,权限码前缀与实体名一致)
- [ ] utoipa 注解已添加(`#[derive(utoipa::OpenApi)]` + path/response schema
- [ ] Service 层核心路径有单元/集成测试
- [ ] 多租户隔离正确(所有查询含 `tenant_id` 过滤,无手写 SQL 拼接)
- [ ] 输入验证完整(必填字段 + 格式校验 + 长度限制)
- [ ] 错误处理统一(`AppError`,不 panic不 unwrap 生产代码)
- [ ] 关键操作有 `tracing` 日志info/warn/error 级别合理)
#### 前端Web
- [ ] API 路径与后端 OpenAPI spec 一致(不手写路径,从 `api/health/` 模块调用)
- [ ] 路由声明权限码(`permissions: [...]`),与后端 handler 一致
- [ ] 菜单配置已更新(`parent_id` 正确 + `permission` 字段 + `menu_roles` 关联)
- [ ] 错误状态有用户友好提示(不显示原始 error message
- [ ] 不使用 `any` 类型(用 `unknown` + 类型守卫)
#### 前端(小程序)
- [ ] Service 层接口契约与后端 DTO 一致(字段名/类型/结构体)
- [ ] 登录态处理正确(`useDidShow` 恢复认证、退出清理 Storage
- [ ] 页面间数据通过 API 获取,不用 Storage 传递
- [ ] 长者模式适配完成(字号 ≥ 22px
- [ ] 图片使用合法 URLHTTPS 或相对路径,不用 HTTP
#### 安全
- [ ] 新增端点有权限声明(默认拒绝,不是默认放行)
- [ ] 敏感数据有脱敏/加密处理PII 字段走 AES-256-GCM
- [ ] 用户输入已验证和消毒(防 SQL 注入、XSS、命令注入、路径穿越
- [ ] 无 CORS 通配符、无硬编码密钥、无 fallback 默认密钥
- [ ] 日志中无敏感数据输出密码、token、身份证号、手机号等
- [ ] 文件上传有 MIME 类型验证 + 大小限制 + 路径穿越防护
- [ ] API 响应不暴露内部实现细节(数据库错误、堆栈跟踪、文件路径)
- [ ] 速率限制已配置(认证端点更严格)
- [ ] 密钥通过环境变量注入,`.env.example` 已同步更新
#### 文档一致性
- [ ] `wiki/index.md` 关键数字与代码实际状态一致(迁移数、路由数、实体数、测试数等)
- [ ] 新增/修复的 bug 已记录在症状导航中(含根因+解决方案)
- [ ] 新增功能已记录在对应模块 wiki 页面中(实体、端点、事件等)
- [ ] wiki 页面的"最后更新"日期已刷新为当天
#### 端到端验证
- [ ] `cargo check` 全 workspace 通过
- [ ] `cargo test` 全部通过
- [ ] 浏览器中手动验证功能正常(列表/创建/编辑/删除/权限拦截)
- [ ] 小程序中验证(涉及小程序页面时)
- [ ] 相关路由权限按角色测试通过(至少 admin + 只读角色)
- [ ] 本地提交已推送到远程仓库
---
## 3. 实现规则
### 3.1 错误处理
- **跨 crate 边界**:使用 `thiserror` 定义类型化错误,转换为 `AppError`
- **crate 内部**:可以使用 `anyhow`,但**永远不**跨越 crate 边界
- **数据库错误**:通过 `From<sea_orm::DbErr>` 自动转换为 `AppError`
- **验证错误**:包含字段级详情,方便 UI 渲染
### 3.2 数据库操作
- 所有 SeaORM Entity 必须包含:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
- 查询时**始终**带 `tenant_id` 过滤(中间件自动注入)
- 更新时检查 `version` 字段实现乐观锁
- 删除使用软删除(设置 `deleted_at`
### 3.3 API 设计
- 所有端点使用 `/api/v1/` 前缀
- 响应统一使用 `ApiResponse<T>` 包装
- 分页使用 `Pagination` + `PaginatedResponse<T>`
- utoipa 自动生成 OpenAPI 文档
- 租户 ID 从 JWT 中间件注入,**不在** API 路径中传递(管理员接口除外)
#### 新增 API 端点安全检查(强制)
> 默认拒绝是安全基线 — 绝大多数安全修复源于默认放行模式。
> 新增端点时**必须**逐项确认:
- [ ] 端点已添加 `require_permission` 权限守卫(非公开端点)
- [ ] 公开端点已显式标记为 `public`(不继承认证中间件)
- [ ] 路由使用 `.nest()` 注册带中间件的子路由(禁止 `.merge()` 防止中间件泄漏)
- [ ] 敏感操作有速率限制
- [ ] 无 `format!` 拼接 SQL — 所有查询使用 SeaORM 参数化
- [ ] FHIR/第三方端点有 `tenant_id` 和 `allowed_patient_ids` 范围过滤
- [ ] 无硬编码密钥或 fallback 默认值
#### 前后端接口同步检查(强制)
> 前后端接口不一致是高频 bug 来源 — 任何 DTO 变更必须双向同步。
> 后端 DTO 变更时**必须**同步检查前端:
- [ ] DTO 字段名变更 → 前端 TypeScript 接口同步更新
- [ ] DTO 新增必填字段 → 前端表单和请求体同步更新
- [ ] API 路径变更 → 前端 `api/` 模块路径同步更新
- [ ] 返回数据结构变更(数组/对象/嵌套)→ 前端解析逻辑同步更新
- [ ] 枚举值变更 → 前端类型定义和 UI 映射同步更新
- [ ] 后端新增端点 → 前端 API 模块同步添加调用函数,不允许留空
#### DTO 输入校验检查(强制)
> DTO 输入校验是安全防线 — 缺失校验等于暴露攻击面Update 和 Create 必须对称。
> 新增/修改 DTO 时**必须**逐项确认:
- [ ] 所有请求结构体已 `derive(Validate)`(包括 Update\*Req、查询参数
- [ ] Update\*Req 与 Create\*Req 校验对称(不允许 Update 降级)
- [ ] 字符串字段有 `#[validate(length(min, max))]`
- [ ] 枚举/类型字段有 `#[validate(custom)]` 限制合法值
- [ ] 集合字段有 `#[validate(length(min = 1))]` 非空检查
- [ ] 数值范围字段有 `#[validate(range(min, max))]`
- [ ] URL 字段有 SSRF 防护(禁止 localhost/内网地址,仅 http/https
- [ ] 密码字段有 `max = 128` 防止 DoS
- [ ] handler 层已调用 `req.validate().map_err(|e| AppError::Validation(e.to_string()))?`
### 3.4 事件总线
- 模块间通信**只能**通过 `EventBus`
- 事件必须持久化到 `domain_events` 表outbox 模式)
- 事件处理失败记录到 dead-letter 存储
- 事件类型命名:`{模块}.{动作}` 如 `user.created`, `workflow.task.completed`
- **铁律:每个事件必须有至少一个消费者,否则功能不算完成。** 新增事件发布时必须同步实现消费者和对应测试。详见 `docs/discussions/2026-04-28-architecture-retrospective.md` §4。
### 3.5 Rust 代码规范
```rust
// 命名snake_case (函数/变量), PascalCase (类型/trait), SCREAMING_SNAKE (常量)
// 模块公开接口通过 lib.rs 统一导出
// 每个 public 函数和 trait 必须有文档注释
// 异步函数返回 Result 时使用 AppResult<T> 类型别名
// 数据库操作使用 SeaORM 的 Entity + Model + Relation 模式
```
### 3.6 TypeScript / React 代码规范
```typescript
// 避免 any优先 unknown + 类型守卫
// 函数组件 + hooks
// 复杂状态收敛到 Zustand store
// API 调用封装到独立的 service 层,不在组件中直接 fetch
// 使用 Ant Design 组件,不自行实现已有组件
// 国际化文案使用 i18n key不硬编码中文
```
### 3.7 安全规范
#### 密钥与凭据管理
- 所有密钥、token、密码**必须**通过环境变量或密钥管理服务注入,**禁止**硬编码在源码中
- 开发环境密钥**必须**与生产环境严格隔离(`cfg(debug_assertions)` 编译期防护)
- 生产密钥**禁止**有 fallback 默认值,缺失时启动 panic
- 新增密钥时必须同步更新 `.env.example` 和 `wiki/infrastructure.md`
#### 依赖安全
- 新增依赖前**必须**检查已知漏洞(`cargo audit` / `npm audit`
- 禁止引入有未修补高危漏洞的依赖版本
- 定期更新依赖到最新安全补丁版本
#### 数据安全
- PII 数据(姓名、身份证号、手机号、地址等)**必须**加密存储AES-256-GCM
- 日志中**禁止**输出 PII 数据和认证凭据密码、token、session key
- 敏感操作(登录、权限变更、数据导出、批量删除)**必须**记录审计日志(操作者、时间、目标、结果)
- 文件上传**必须**验证 MIME 类型 + 限制文件大小 + 防止路径穿越(文件名 sanitize
#### 传输安全
- 生产环境**必须**强制 HTTPS**禁止**降级到 HTTP
- HTTP 响应**必须**包含安全头HSTS、CSP、X-Frame-Options、X-Content-Type-Options、Permissions-Policy
- SSE/WebSocket 长连接认证 token 不通过 URL query 参数传递(使用 header 或 cookie
- API 响应**禁止**暴露内部实现细节堆栈跟踪、数据库错误、文件路径、SQL 语句)
#### 认证与授权
- 密码**必须**使用单向哈希bcrypt/argon2**禁止**明文或可逆加密存储
- JWT **必须**设置合理过期时间,支持 token 吊销机制
- 敏感操作(删除数据、权限变更)需要二次确认
- 权限检查在 handler 层执行,**禁止**仅依赖前端隐藏控制访问
- `tenant_id` **必须**从 JWT 中间件注入,**禁止**信任客户端传递的值
#### 速率限制
- 所有 API 端点**必须**配置速率限制
- 认证相关端点(登录、注册、密码重置)限制更严格
- 批量操作和数据导出需要独立的速率限制策略
- 速率限制超出时返回 429 状态码,响应包含 `Retry-After` header
---
## 4. 测试与验证
### 4.1 测试要求
| 测试类型 | 覆盖目标 | 工具 |
|----------|---------|------|
| 单元测试 | 每个 service 函数 | `#[cfg(test)]` + `tokio::test` |
| 集成测试 | API 端点 → 数据库 | Testcontainers + 真实 PostgreSQL |
| 多租户测试 | 数据隔离验证 | 独立测试 crate |
| E2E 测试 | 前端关键流程 | Playwright |
| 插件测试 | 动态表 CRUD + 租户隔离 | Testcontainers |
### 4.2 验证命令
```bash
# Rust 编译检查
cargo check
# Rust 全量测试
cargo test --workspace
# 后端服务启动
cd crates/erp-server && cargo run
# Docker 环境
cd docker && docker compose up -d
# 桌面端开发
cd apps/desktop && pnpm tauri dev
# 数据库迁移检查
docker exec erp-postgres psql -U erp -c "\dt"
```
### 4.3 Phase 完成标准
每个 Phase 完成时必须满足:
- [ ] `cargo check` 全 workspace 通过
- [ ] `cargo test` 全部通过
- [ ] PostgreSQL 服务正常运行,迁移自动执行
- [ ] 所有迁移可正/反向执行
- [ ] API 端点可通过 Swagger UI 测试
- [ ] 桌面端可正常启动并展示对应 UI
- [ ] 所有代码已提交
---
## 5. 提交规范
```
<type>(<scope>): <description>
```
**类型:**
- `feat` — 新功能
- `fix` — 修复问题
- `refactor` — 重构
- `docs` — 文档更新
- `test` — 测试相关
- `chore` — 杂项(构建、配置等)
- `perf` — 性能优化
**Scope 对应 crate 或模块名:**
| scope | 范围 |
|-------|------|
| `core` | erp-core |
| `auth` | erp-auth |
| `workflow` | erp-workflow |
| `message` | erp-message |
| `config` | erp-config |
| `server` | erp-server |
| `health` | erp-health |
| `ai` | erp-ai |
| `dialysis` | erp-dialysis |
| `plugin` | erp-plugin / erp-plugin-prototype / erp-plugin-test-sample |
| `assessment` | erp-plugin-assessment |
| `crm` | erp-plugin-crm |
| `inventory` | erp-plugin-inventory |
| `web` | Web 前端 |
| `ui` | React 组件 |
| `db` | 数据库迁移 |
| `docker` | Docker 配置 |
**示例:**
```
feat(auth): 添加用户管理 CRUD
feat(core): 实现事件总线和模块注册
fix(server): 修复数据库连接池配置
refactor(auth): 拆分 RBAC 和 ABAC 权限模型
chore(docker): 添加 PostgreSQL 健康检查
```
---
## 6. 反模式警告
- ❌ **不要**不看 wiki 就开干 — wiki 包含环境配置、数据库连接、启动方式、已知问题,不看就做等于盲猜,浪费时间且产出不可信
- ❌ **不要**在业务 crate 之间创建直接依赖 — 只通过事件和 trait 通信
- ❌ **不要**跳过多租户中间件 — 所有数据操作必须带 `tenant_id` 过滤
- ❌ **不要**硬编码配置值 — 使用 config.toml + 环境变量
- ❌ **不要**跳过迁移直接建表 — 所有 schema 变更通过 SeaORM Migration
- ❌ **不要**在前端组件中直接调用 HTTP — 封装到 service 层
- ❌ **不要**使用 `anyhow` 跨越 crate 边界 — 内部可用,对外必须转 `AppError`
- ❌ **不要**假设只有单租户 — 从第一天就按多租户设计
- ❌ **不要**提前实现远期功能 — 严格按 Phase 计划推进
- ❌ **不要**忽略 `version` 字段 — 所有更新操作必须检查乐观锁
- ❌ **不要**在动态表 SQL 中拼接用户输入 — 使用 `sanitize_identifier` 防注入
- ❌ **不要**在插件 crate 中直接依赖 erp-auth — 权限注册用 raw SQL保持模块边界
- ❌ **不要**在 plugin.toml 中使用与实体名不一致的权限码 — `permissions[].code` 前缀必须与 `schema.entities[].name` 完全一致(如实体 `customer_tag` → 权限码 `customer_tag.list`/`customer_tag.manage`,不能写成 `tag.manage`),否则页面 403
- ❌ **不要**漏掉实体的 `.list` 权限 — 每个实体必须同时声明 `.list` 和 `.manage`,缺少 `.list` 导致列表页 403
- ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步。每次新会话开始先检查未推送提交
- ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档
- ❌ **不要**连续 5 个代码提交不更新 wiki — wiki 是团队唯一真相源,过期数据比没有数据更有害
- ❌ **不要**修复 bug 后跳过症状导航更新 — 每个修复都应该帮助未来遇到同类问题的人快速定位根因
- ❌ **不要**新增功能后不更新 wiki 关键数字 — 迁移数/路由数/实体数/测试数必须与代码同步,否则 wiki 指标表就是废数据
- ❌ **不要**一次性输出长文档 — 超过 200 行的文档必须分步编写(先大纲 → 逐章 → 整合),否则会因上下文过长卡死
- ❌ **不要**忽略讨论记录 — 每次发散式讨论结束后必须建立文档到 `docs/discussions/`,不要口头确认后就忘
- ❌ **不要**跳过 Feature DoD — 每个功能标记"完成"前必须通过 §2.6 检查清单,不全面验证就提交将导致后续反复修复
- ❌ **不要**新增端点时默认放行 — 所有端点默认拒绝访问,必须显式声明权限
- ❌ **不要**后端 DTO 变更不同步前端 — 字段名/路径/类型变更时必须同步更新前端 TypeScript 接口
- ❌ **不要**写 Update DTO 时省略校验 — Update*Req 必须与 Create*Req 校验对称Validate derive / 枚举 custom / Vec min=1 / 密码 max=128
- ❌ **不要**URL 字段不做 SSRF 防护 — 禁止 localhost/127.0.0.1/内网地址,仅允许 http/https 协议
- ❌ **不要**handler 层跳过 `.validate()` — 所有 `Json<T>` handler 函数体第一行必须调 `req.validate().map_err(\|e\| AppError::Validation(e.to_string()))?`
- ❌ **不要**在日志中输出敏感数据 — 密码、token、身份证号、手机号等 PII 信息禁止写入日志
- ❌ **不要**信任客户端传递的 `tenant_id` — 必须从 JWT 中间件注入,客户端可伪造
- ❌ **不要**在生产代码中使用 `unwrap()` — 必须处理所有错误,使用 `?` 或 `map_err`
- ❌ **不要**将内部错误信息返回给客户端 — 数据库错误、堆栈跟踪、文件路径等必须转换为用户友好的错误消息
- ❌ **不要**使用 HTTP 传输敏感数据 — 生产环境必须 HTTPS
- ❌ **不要**跳过依赖安全检查 — 新增依赖前运行 `cargo audit` / `npm audit`,禁止引入有高危漏洞的版本
- ❌ **不要**文件上传不做安全处理 — 必须验证 MIME 类型 + 限制大小 + sanitize 文件名防路径穿越
### 场景化指令
- 当遇到**新增模块** → 实现 `ErpModule` trait在 `erp-server` 注册
- 当遇到**跨模块通信** → 定义事件类型,通过 `EventBus` 发布/订阅
- 当遇到**数据查询** → 确保包含 `tenant_id` 过滤,检查软删除条件
- 当遇到**新增 API** → 添加 utoipa 注解,确保 OpenAPI 文档同步
- 当遇到**新增表** → 创建 SeaORM migration + Entity包含所有标准字段
- 当遇到**新增页面** → 使用 Ant Design 组件i18n key 引用文案
- 当遇到**新增业务模块插件** → 参考 `wiki/wasm-plugin.md` 的插件制作完整流程和 `.claude/skills/plugin-development/SKILL.md`,创建 cdylib crate + 实现 Guest trait + 编译为 WASM Component。**权限码必须与实体名一致(每个实体声明 `.list` + `.manage`**
- 当遇到**新增/修改 DTO** → 参考 `wiki/architecture.md` §4 DTO 输入校验规范:`derive(Validate)` + 字段级校验 + handler 层 `validate()` 调用 + 单元测试
---
## 7. 详细参考wiki
以下内容已从本文件迁移到 wiki需要时查阅
| 主题 | wiki 页面 |
|------|----------|
| 目录结构、crate 依赖、技术栈 | `wiki/architecture.md` §2 |
| 模块开发规范、ErpModule trait、迁移规范 | `wiki/architecture.md` §3 |
| 安全注意事项(认证/多租户/通用) | `wiki/architecture.md` §4 |
| UI 布局规范 | `wiki/frontend.md` §2 |
| 常用命令Rust/前端/数据库/WASM | `wiki/infrastructure.md` §3 |
| 设计文档索引 | `wiki/index.md` |
| 开发进度、模块状态 | `wiki/index.md` 关键数字 |
| 环境配置、连接信息、登录凭据 | `wiki/infrastructure.md` §2 |
## graphify — 代码知识图谱
> 项目知识图谱位于 `graphify-out/`当前规模18,517 节点 / 22,666 边 / 1,841 社区(纯 AST 解析,无 API 成本)。
> 工具:`python -m graphify`(已安装 graphifyy 0.8.18)。
### 开发流程中的使用场景
| 时机 | 命令 | 目的 |
|------|------|------|
| **接手新任务,理解代码关系** | `graphify query "概念名"` | 搜索相关节点,比 Grep 更精准(按调用/引用/包含关系) |
| **排查 bug追踪调用链** | `graphify path "A" "B"` | 查找两个模块/函数间的最短路径 |
| **理解某个模块的职责** | `graphify explain "模块名"` | 自然语言解释节点及其邻居 |
| **代码改动后** | `graphify update .` | 增量更新图谱AST-only秒级完成 |
| **宏观架构审查** | 读 `graphify-out/GRAPH_REPORT.md` | 全局社区结构、跨文件关系概览 |
### 使用优先级(融入 §2.5 闭环工作法)
在 §2.5 步骤 1「现状确认」中**优先使用 graphify 替代盲目 Grep**
1. **先 `graphify query`** — 精确定位相关节点和社区(比 Grep 返回更结构化的结果)
2. **再 `graphify path`** — 确认模块间依赖路径(避免遗漏间接依赖)
3. **最后 Grep/Glob/Read** — 确认 graphify 发现的具体文件内容
### 注意事项
- `graphify update .` 纯本地 AST 解析,不消耗 LLM token每次代码改动后都可以运行
- 查询结果比 GRAPH_REPORT.md 更精准,优先使用 query/path/explain仅在需要全局视图时读报告
- 首次生成需几分钟1712 文件),后续增量更新秒级完成

6730
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

115
Cargo.toml Normal file
View File

@@ -0,0 +1,115 @@
[workspace]
resolver = "2"
members = [
"crates/erp-core",
"crates/erp-server",
"crates/erp-auth",
"crates/erp-workflow",
"crates/erp-message",
"crates/erp-config",
"crates/erp-server/migration",
"crates/erp-plugin",
]
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT"
[workspace.dependencies]
# Async
tokio = { version = "1", features = ["full"] }
# Web
axum = { version = "0.8", features = ["multipart"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "fs", "set-header"] }
# Database
sea-orm = { version = "1.1", features = [
"sqlx-postgres", "runtime-tokio-rustls", "macros", "with-uuid", "with-chrono", "with-json"
] }
sea-orm-migration = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# UUID & Time
uuid = { version = "1", features = ["v7", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
# Error handling
thiserror = "2"
anyhow = "1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Config
config = "0.14"
# Redis
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
# JWT
jsonwebtoken = "9"
# Password hashing
argon2 = "0.5"
# Cryptographic hashing (token storage)
sha2 = "0.10"
# API docs
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
# utoipa-swagger-ui 需要下载 GitHub 资源,网络受限时暂不使用
# utoipa-swagger-ui = { version = "8", features = ["axum"] }
# Validation
validator = { version = "0.19", features = ["derive"] }
# Async trait
async-trait = "0.1"
# HTTP client
reqwest = { version = "0.12", features = ["json", "stream"] }
# Crypto
aes = "0.8"
cbc = "0.1"
hex = "0.4"
regex-lite = "0.1"
# CSV and Excel export
csv = "1"
rust_xlsxwriter = "0.82"
# Internal crates
erp-core = { path = "crates/erp-core" }
erp-auth = { path = "crates/erp-auth" }
erp-workflow = { path = "crates/erp-workflow" }
erp-message = { path = "crates/erp-message" }
erp-config = { path = "crates/erp-config" }
erp-plugin = { path = "crates/erp-plugin" }
# Async streaming
futures = "0.3"
tokio-stream = "0.1"
async-stream = "0.3"
dashmap = "6"
# Template engine
handlebars = "6"
# HTML sanitization
ammonia = "4"
# Document parsing
pdf-extract = "0.7"
# Metrics
metrics = "0.24"
metrics-exporter-prometheus = "0.16"

113
Dockerfile Normal file
View File

@@ -0,0 +1,113 @@
# ==============================
# Stage 1: Build Rust backend
# ==============================
FROM rust:1-bookworm AS rust-builder
WORKDIR /app
# 先复制依赖文件以利用 Docker 缓存
COPY Cargo.toml Cargo.lock ./
COPY crates/erp-core/Cargo.toml crates/erp-core/Cargo.toml
COPY crates/erp-auth/Cargo.toml crates/erp-auth/Cargo.toml
COPY crates/erp-config/Cargo.toml crates/erp-config/Cargo.toml
COPY crates/erp-workflow/Cargo.toml crates/erp-workflow/Cargo.toml
COPY crates/erp-message/Cargo.toml crates/erp-message/Cargo.toml
COPY crates/erp-plugin/Cargo.toml crates/erp-plugin/Cargo.toml
COPY crates/erp-health/Cargo.toml crates/erp-health/Cargo.toml
COPY crates/erp-ai/Cargo.toml crates/erp-ai/Cargo.toml
COPY crates/erp-dialysis/Cargo.toml crates/erp-dialysis/Cargo.toml
COPY crates/erp-server/Cargo.toml crates/erp-server/Cargo.toml
COPY crates/erp-server/migration/Cargo.toml crates/erp-server/migration/Cargo.toml
COPY crates/erp-plugin-prototype/Cargo.toml crates/erp-plugin-prototype/Cargo.toml
COPY crates/erp-plugin-test-sample/Cargo.toml crates/erp-plugin-test-sample/Cargo.toml
COPY crates/erp-plugin-assessment/Cargo.toml crates/erp-plugin-assessment/Cargo.toml
COPY crates/erp-plugin-crm/Cargo.toml crates/erp-plugin-crm/Cargo.toml
COPY crates/erp-plugin-freelance/Cargo.toml crates/erp-plugin-freelance/Cargo.toml
COPY crates/erp-plugin-inventory/Cargo.toml crates/erp-plugin-inventory/Cargo.toml
COPY crates/erp-plugin-itops/Cargo.toml crates/erp-plugin-itops/Cargo.toml
# 创建空的 lib.rs/main.rs 占位以缓存依赖
RUN mkdir -p crates/erp-core/src && echo "" > crates/erp-core/src/lib.rs \
&& mkdir -p crates/erp-auth/src && echo "" > crates/erp-auth/src/lib.rs \
&& mkdir -p crates/erp-config/src && echo "" > crates/erp-config/src/lib.rs \
&& mkdir -p crates/erp-workflow/src && echo "" > crates/erp-workflow/src/lib.rs \
&& mkdir -p crates/erp-message/src && echo "" > crates/erp-message/src/lib.rs \
&& mkdir -p crates/erp-plugin/src && echo "" > crates/erp-plugin/src/lib.rs \
&& mkdir -p crates/erp-health/src && echo "" > crates/erp-health/src/lib.rs \
&& mkdir -p crates/erp-ai/src && echo "" > crates/erp-ai/src/lib.rs \
&& mkdir -p crates/erp-dialysis/src && echo "" > crates/erp-dialysis/src/lib.rs \
&& mkdir -p crates/erp-server/src && echo "fn main(){}" > crates/erp-server/src/main.rs \
&& mkdir -p crates/erp-server/migration/src && echo "" > crates/erp-server/migration/src/lib.rs \
&& for crate in erp-plugin-prototype erp-plugin-test-sample erp-plugin-assessment erp-plugin-crm erp-plugin-freelance erp-plugin-inventory erp-plugin-itops; do \
mkdir -p crates/$crate/src && echo "" > crates/$crate/src/lib.rs; \
done
# 构建依赖(仅当 Cargo.toml/Cargo.lock 变化时重新编译)
RUN cargo build --release -p erp-server 2>/dev/null || true
# 复制实际源码
COPY crates/ crates/
# 重新构建(增量编译,只编译业务代码)
RUN cargo build --release -p erp-server
# ==============================
# Stage 2: Build frontend
# ==============================
FROM node:20-alpine AS frontend-builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY apps/web/package.json apps/web/pnpm-lock.yaml ./apps/web/
RUN cd apps/web && pnpm install --frozen-lockfile
COPY apps/web/ ./apps/web/
RUN cd apps/web && pnpm build
# ==============================
# Stage 3: Production runtime
# ==============================
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 复制 Rust 二进制
COPY --from=rust-builder /app/target/release/erp-server /app/erp-server
# 复制配置文件
COPY config/ /app/config/
# 复制前端构建产物(可通过 volume 暴露给 OpenResty
COPY --from=frontend-builder /app/apps/web/dist/ /app/static/
# 创建上传目录
RUN mkdir -p /app/uploads
# 非特权用户运行
RUN useradd -r -s /bin/false appuser \
&& chown -R appuser:appuser /app
USER appuser
# 环境变量(运行时通过 docker-compose / .env 覆盖)
ENV ERP__SERVER__HOST=0.0.0.0
ENV ERP__SERVER__PORT=3000
ENV ERP__SERVER__METRICS_PORT=9090
ENV ERP__STORAGE__UPLOAD_DIR=/app/uploads
EXPOSE 3000 9090
VOLUME ["/app/uploads", "/app/static"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -f http://localhost:3000/api/v1/health || exit 1
ENTRYPOINT ["/app/erp-server"]

64
README.md Normal file
View File

@@ -0,0 +1,64 @@
# ERP Platform Base
模块化商业 SaaS ERP 平台底座。
## 技术栈
| 层级 | 技术 |
|------|------|
| 后端 | Rust (Axum 0.8 + SeaORM + Tokio) |
| 数据库 | PostgreSQL 16+ |
| 缓存 | Redis 7+ |
| 前端 | Vite + React 18 + TypeScript + Ant Design 5 |
## 项目结构
```
erp/
├── crates/
│ ├── erp-core/ # 基础类型、错误、事件总线、模块 trait
│ ├── erp-common/ # 共享工具
│ ├── erp-auth/ # 身份与权限 (Phase 2)
│ ├── erp-workflow/ # 工作流引擎 (Phase 4)
│ ├── erp-message/ # 消息中心 (Phase 5)
│ ├── erp-config/ # 系统配置 (Phase 3)
│ └── erp-server/ # Axum 服务入口
│ └── migration/ # SeaORM 数据库迁移
├── apps/web/ # React SPA 前端
├── docker/ # Docker 开发环境
└── docs/ # 文档
```
## 快速开始
### 1. 启动基础设施
```bash
cd docker && docker compose up -d
```
### 2. 启动后端
```bash
cargo run -p erp-server
```
### 3. 启动前端
```bash
cd apps/web && pnpm install && pnpm dev
```
### 4. 访问
- 前端: http://localhost:5173
- 后端 API: http://localhost:3000
## 开发命令
```bash
cargo check # 编译检查
cargo test --workspace # 运行测试
cargo run -p erp-server # 启动后端
cd apps/web && pnpm dev # 启动前端
```

View File

@@ -0,0 +1,43 @@
import { test as base } from '@playwright/test';
const API_BASE = 'http://localhost:3000/api/v1';
let loginPromise: Promise<{ token: string; user: unknown }> | null = null;
function login(): Promise<{ token: string; user: unknown }> {
if (!loginPromise) {
loginPromise = (async () => {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'Admin@2026' }),
});
const json = await res.json();
if (json.success) {
return { token: json.data.access_token, user: json.data.user };
}
} catch {}
// Wait before retry on collision
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
}
throw new Error('Login failed after 3 attempts');
})();
}
return loginPromise;
}
export const test = base.extend({
page: async ({ page }, use) => {
const { token, user } = await login();
await page.addInitScript((args) => {
localStorage.setItem('access_token', args.token);
localStorage.setItem('refresh_token', args.token);
localStorage.setItem('user', JSON.stringify(args.user));
}, { token, user });
await use(page);
},
});
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,22 @@
// apps/web/e2e/check-readiness.ts
import type { FullConfig } from '@playwright/test';
async function check(url: string, label: string): Promise<void> {
for (let i = 0; i < 5; i++) {
try {
const res = await fetch(url);
if (res.ok) return;
} catch { /* retry */ }
console.log(`${label} 未就绪,等待重试 (${i + 1}/5)...`);
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`${label} 未就绪: ${url}。请确认后端服务已启动 (cd crates/erp-server && cargo run)`);
}
export default async function globalSetup(_config: FullConfig) {
const apiBase = process.env.E2E_API_URL || 'http://localhost:3000';
const webBase = process.env.E2E_BASE_URL || 'http://localhost:5174';
await check(`${apiBase}/api/v1/health`, '后端 API');
await check(webBase, '前端 SPA');
console.log('✅ E2E 环境就绪');
}

View File

@@ -0,0 +1,200 @@
// apps/web/e2e/fixtures/api-client.ts
import type {
PatientData, DoctorData, VitalSignsData, ScheduleData,
AppointmentData, FollowUpTemplateData, FollowUpTaskData, AlertRuleData,
} from './test-data';
const API_BASE = process.env.E2E_API_URL || 'http://localhost:3000/api/v1';
interface ApiResponse<T> { success: boolean; data: T }
interface Versioned { id: string; version: number }
type VEntity<T> = T & Versioned;
interface LoginResponse {
access_token: string;
refresh_token: string;
expires_in: number;
user: { id: string; username: string; display_name: string; roles: string[] };
}
export class ApiClient {
private token = '';
async login(username?: string, password?: string): Promise<LoginResponse> {
const res = await this.rawPost<{ success: boolean; data: LoginResponse }>(
'/auth/login',
{
username: username || process.env.E2E_ADMIN_USER || 'admin',
password: password || process.env.E2E_ADMIN_PASS || 'Admin@2026',
},
);
this.token = res.data.access_token;
return res.data;
}
async loginAsAdmin(): Promise<LoginResponse> {
return this.login();
}
getToken(): string { return this.token; }
async createPatient(overrides?: Partial<PatientData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/patients', overrides ?? {});
}
async updatePatient(id: string, version: number, data: Partial<PatientData>): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/patients/${id}`, { ...data, version });
}
async deletePatient(id: string, version: number): Promise<void> {
await this.del(`/health/patients/${id}`, { version });
}
async createDoctor(overrides?: Partial<DoctorData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/doctors', overrides ?? {});
}
async deleteDoctor(id: string, version: number): Promise<void> {
await this.del(`/health/doctors/${id}`, { version });
}
async createVitalSigns(patientId: string, overrides?: Partial<VitalSignsData>): Promise<VEntity<Record<string, unknown>>> {
return this.post(`/health/patients/${patientId}/vital-signs`, overrides ?? {});
}
async deleteVitalSigns(patientId: string, id: string, version: number): Promise<void> {
await this.del(`/health/patients/${patientId}/vital-signs/${id}`, { version });
}
async createSchedule(overrides: ScheduleData): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/doctor-schedules', overrides);
}
async deleteSchedule(id: string, version: number): Promise<void> {
await this.del(`/health/doctor-schedules/${id}`, { version });
}
async createAppointment(overrides: AppointmentData): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/appointments', overrides);
}
async updateAppointmentStatus(id: string, version: number, status: string): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/appointments/${id}/status`, { status, version });
}
async deleteAppointment(id: string, version: number): Promise<void> {
await this.del(`/health/appointments/${id}`, { version });
}
async createFollowUpTemplate(overrides?: Partial<FollowUpTemplateData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/follow-up-templates', overrides ?? {});
}
async deleteFollowUpTemplate(id: string, version: number): Promise<void> {
await this.del(`/health/follow-up-templates/${id}`, { version });
}
async createFollowUpTask(overrides: FollowUpTaskData): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/follow-up-tasks', overrides);
}
async deleteFollowUpTask(id: string, version: number): Promise<void> {
await this.del(`/health/follow-up-tasks/${id}`, { version });
}
async createAlertRule(overrides?: Partial<AlertRuleData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/alert-rules', overrides ?? {});
}
async deleteAlertRule(id: string, version: number): Promise<void> {
await this.del(`/health/alert-rules/${id}`, { version });
}
async listAlerts(): Promise<VEntity<Record<string, unknown>>[]> {
const res = await this.get<{ data: VEntity<Record<string, unknown>>[] }>('/health/alerts');
return res.data ?? [];
}
async acknowledgeAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/alerts/${id}/acknowledge`, { version });
}
async resolveAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/alerts/${id}/resolve`, { version });
}
async dismissAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/alerts/${id}/dismiss`, { version });
}
private async headers(): Promise<Record<string, string>> {
return {
'Content-Type': 'application/json',
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
};
}
private async parseJson<T>(res: Response, method: string, path: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${method} ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
if (!json.success) throw new Error(`${method} ${path} failed: ${json.error ?? 'unknown'}`);
return json.data as T;
}
private async get<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, { headers: await this.headers() });
return this.parseJson<T>(res, 'GET', path);
}
private async post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: await this.headers(),
body: JSON.stringify(body),
});
return this.parseJson<T>(res, 'POST', path);
}
private async put<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'PUT',
headers: await this.headers(),
body: JSON.stringify(body),
});
return this.parseJson<T>(res, 'PUT', path);
}
private async del(path: string, body?: unknown): Promise<void> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'DELETE',
headers: await this.headers(),
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return;
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`DELETE ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
if (!json.success) throw new Error(`DELETE ${path} failed: ${json.error ?? 'unknown'}`);
}
private async rawPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`POST ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
if (!json.success) throw new Error(`POST ${path} failed: ${json.error ?? 'unknown'}`);
return json as T;
}
}

View File

@@ -0,0 +1,73 @@
// apps/web/e2e/fixtures/auth.fixture.ts
import { test as base, type Page } from '@playwright/test';
import { ApiClient } from './api-client';
const API_BASE = process.env.E2E_API_URL || 'http://localhost:3000/api/v1';
type E2eFixtures = {
api: ApiClient;
authenticatedPage: Page;
};
interface LoginResult {
access_token: string;
refresh_token: string;
user: object;
}
async function login(): Promise<LoginResult> {
for (let attempt = 0; attempt < 5; attempt++) {
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: process.env.E2E_ADMIN_USER || 'admin',
password: process.env.E2E_ADMIN_PASS || 'Admin@2026',
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}: ${text.slice(0, 100)}`);
}
const json = await res.json();
if (json.success) return json.data;
throw new Error(`Login unsuccessful: ${json.error ?? 'unknown'}`);
} catch (err) {
if (attempt === 4) throw err;
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
}
}
throw new Error('Login failed after 5 attempts');
}
export const test = base.extend<E2eFixtures>({
api: async ({}, use) => {
const { access_token } = await login();
const client = new ApiClient();
client['token'] = access_token;
await use(client);
},
authenticatedPage: async ({ page }, use) => {
const { access_token, refresh_token, user } = await login();
await page.addInitScript((args) => {
localStorage.setItem('access_token', args.token);
localStorage.setItem('refresh_token', args.refresh);
localStorage.setItem('user', JSON.stringify(args.userData));
}, { token: access_token, refresh: refresh_token, userData: user });
await use(page);
},
page: async ({ page }, use) => {
const { access_token, refresh_token, user } = await login();
await page.addInitScript((args) => {
localStorage.setItem('access_token', args.token);
localStorage.setItem('refresh_token', args.refresh);
localStorage.setItem('user', JSON.stringify(args.userData));
}, { token: access_token, refresh: refresh_token, userData: user });
await use(page);
},
});
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,198 @@
// apps/web/e2e/fixtures/test-data.ts
export interface PatientData {
name: string;
gender?: string;
birth_date?: string;
blood_type?: string;
id_number?: string;
allergy_history?: string;
medical_history_summary?: string;
emergency_contact_name?: string;
emergency_contact_phone?: string;
source?: string;
notes?: string;
}
export interface DoctorData {
name: string;
department?: string;
title?: string;
specialty?: string;
phone?: string;
license_number?: string;
status?: string;
}
export interface VitalSignsData {
record_date: string;
systolic_bp_morning?: number;
diastolic_bp_morning?: number;
heart_rate?: number;
body_temperature?: number;
spo2?: number;
blood_sugar?: number;
weight?: number;
water_intake_ml?: number;
urine_output_ml?: number;
notes?: string;
source?: string;
}
export interface ScheduleData {
doctor_id: string;
schedule_date: string;
start_time: string;
end_time: string;
max_appointments?: number;
period_type?: string;
}
export interface AppointmentData {
patient_id: string;
doctor_id: string;
schedule_id: string;
appointment_date: string;
start_time: string;
end_time: string;
reason?: string;
}
export interface FollowUpTemplateData {
name: string;
description?: string;
follow_up_type: string;
applicable_scope?: string;
fields?: Array<{
label: string;
field_key: string;
field_type: string;
required?: boolean;
options?: string;
}>;
}
export interface FollowUpTaskData {
patient_id: string;
follow_up_type: string;
planned_date: string;
assigned_to?: string;
content_template?: string;
}
export interface AlertRuleData {
name: string;
device_type: string;
condition_type: string;
condition_params: Record<string, unknown>;
severity?: string;
description?: string;
apply_tags?: Record<string, unknown>;
notify_roles?: Array<string>;
cooldown_minutes?: number;
}
let counter = 0;
function uid(): string {
counter += 1;
return `${Date.now()}_${counter}_${Math.random().toString(36).slice(2, 6)}`;
}
export function makePatient(overrides?: Partial<PatientData>): PatientData {
const id = uid();
return {
name: `E2E患者_${id}`,
gender: 'male',
birth_date: '1990-01-15',
id_number: `110101199001${String(Math.random()).slice(2, 8)}`,
...overrides,
};
}
export function makeDoctor(overrides?: Partial<DoctorData>): DoctorData {
const id = uid();
return {
name: `E2E医生_${id}`,
department: '内科',
title: '主治医师',
specialty: '全科',
license_number: `DOC${id}`,
...overrides,
};
}
export function makeVitalSigns(overrides?: Partial<VitalSignsData>): VitalSignsData {
return {
record_date: new Date().toISOString().slice(0, 10),
systolic_bp_morning: 120,
diastolic_bp_morning: 80,
heart_rate: 72,
body_temperature: 36.5,
spo2: 98,
source: 'web_e2e',
...overrides,
};
}
export function makeSchedule(doctorId: string, overrides?: Partial<ScheduleData>): ScheduleData {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const date = tomorrow.toISOString().slice(0, 10);
return {
doctor_id: doctorId,
schedule_date: date,
start_time: '09:00',
end_time: '12:00',
max_appointments: 10,
...overrides,
};
}
export function makeAppointment(patientId: string, doctorId: string, scheduleId: string, overrides?: Partial<AppointmentData>): AppointmentData {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const date = tomorrow.toISOString().slice(0, 10);
return {
patient_id: patientId,
doctor_id: doctorId,
schedule_id: scheduleId,
appointment_date: date,
start_time: '09:00',
end_time: '10:00',
reason: 'E2E测试预约',
...overrides,
};
}
export function makeFollowUpTemplate(overrides?: Partial<FollowUpTemplateData>): FollowUpTemplateData {
return {
name: `E2E随访模板_${uid()}`,
description: 'E2E自动创建的随访模板',
follow_up_type: 'phone',
...overrides,
};
}
export function makeFollowUpTask(patientId: string, _templateId: string, overrides?: Partial<FollowUpTaskData>): FollowUpTaskData {
const plannedDate = new Date();
plannedDate.setDate(plannedDate.getDate() + 7);
return {
patient_id: patientId,
follow_up_type: 'phone',
planned_date: plannedDate.toISOString().slice(0, 10),
...overrides,
};
}
export function makeAlertRule(overrides?: Partial<AlertRuleData>): AlertRuleData {
return {
name: `E2E告警规则_${uid()}`,
device_type: 'heart_rate',
condition_type: 'single_threshold',
condition_params: { direction: 'above', value: 50 },
severity: 'warning',
description: 'E2E测试低阈值规则用于触发告警',
...overrides,
};
}

View File

@@ -0,0 +1,28 @@
// apps/web/e2e/flows/alert-flow.spec.ts
import { test, expect } from '../fixtures/auth.fixture';
import { makeAlertRule } from '../fixtures/test-data';
test.describe('@flow 告警规则链路', () => {
const cleanup: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const fn of cleanup.reverse()) {
await fn().catch(() => {});
}
cleanup.length = 0;
});
test('创建告警规则 → 查看列表 → 查看告警页面', async ({ api, authenticatedPage: page }) => {
const rule = await api.createAlertRule(makeAlertRule());
cleanup.push(() => api.deleteAlertRule(rule.id, rule.version));
await page.goto('/#/health/alert-rules');
await page.waitForSelector('.ant-table', { timeout: 10000 });
const tableText = await page.locator('.ant-table-tbody').textContent();
expect(tableText).toBeTruthy();
await page.goto('/#/health/alerts');
await page.waitForSelector('.ant-table, .ant-empty', { timeout: 10000 });
});
});

View File

@@ -0,0 +1,38 @@
// apps/web/e2e/flows/appointment-flow.spec.ts
import { test, expect } from '../fixtures/auth.fixture';
import { AppointmentPage } from '../pages/appointment.page';
import { makePatient, makeDoctor, makeSchedule, makeAppointment } from '../fixtures/test-data';
test.describe('@flow 预约排班链路', () => {
const cleanup: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const fn of cleanup.reverse()) {
await fn().catch(() => {});
}
cleanup.length = 0;
});
test('创建医生 → 设置排班 → 创建预约 → 查看列表', async ({ api, authenticatedPage: page }) => {
const doctor = await api.createDoctor(makeDoctor());
cleanup.push(() => api.deleteDoctor(doctor.id, doctor.version));
const patient = await api.createPatient(makePatient());
cleanup.push(() => api.deletePatient(patient.id, patient.version));
const schedule = await api.createSchedule(makeSchedule(doctor.id));
cleanup.push(() => api.deleteSchedule(schedule.id, schedule.version));
const appointmentPage = new AppointmentPage(page);
await appointmentPage.gotoSchedule();
const appointment = await api.createAppointment(
makeAppointment(patient.id, doctor.id, schedule.id),
);
cleanup.push(() => api.deleteAppointment(appointment.id, appointment.version));
await appointmentPage.gotoAppointments();
const tableText = await page.locator('.ant-table-tbody').textContent();
expect(tableText).toBeTruthy();
});
});

View File

@@ -0,0 +1,36 @@
// apps/web/e2e/flows/follow-up-flow.spec.ts
import { test, expect } from '../fixtures/auth.fixture';
import { makePatient, makeFollowUpTemplate, makeFollowUpTask } from '../fixtures/test-data';
test.describe('@flow 随访管理链路', () => {
const cleanup: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const fn of cleanup.reverse()) {
await fn().catch(() => {});
}
cleanup.length = 0;
});
test('创建模板 → 创建任务 → 查看任务列表', async ({ api, authenticatedPage: page }) => {
const patient = await api.createPatient(makePatient());
cleanup.push(() => api.deletePatient(patient.id, patient.version));
const template = await api.createFollowUpTemplate(makeFollowUpTemplate());
cleanup.push(() => api.deleteFollowUpTemplate(template.id, template.version));
await page.goto('/#/health/follow-up-tasks');
await page.waitForSelector('.ant-table', { timeout: 10000 });
const task = await api.createFollowUpTask(
makeFollowUpTask(patient.id, template.id),
);
cleanup.push(() => api.deleteFollowUpTask(task.id, task.version));
await page.reload();
await page.waitForSelector('.ant-table');
const rowCount = await page.locator('.ant-table-tbody tr').count();
expect(rowCount).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,53 @@
// apps/web/e2e/flows/patient-journey.spec.ts
import { test, expect } from '../fixtures/auth.fixture';
import { PatientListPage } from '../pages/patient-list.page';
import { PatientDetailPage } from '../pages/patient-detail.page';
import { makePatient, makeDoctor } from '../fixtures/test-data';
test.describe('@flow 患者全流程', () => {
const cleanup: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const fn of cleanup.reverse()) {
await fn().catch(() => {});
}
cleanup.length = 0;
});
test('创建患者 → 查看详情 → 编辑 → 分配医生', async ({ api, authenticatedPage: page }) => {
const doctorData = makeDoctor();
const doctor = await api.createDoctor(doctorData);
cleanup.push(() => api.deleteDoctor(doctor.id, doctor.version));
const listPage = new PatientListPage(page);
await listPage.goto();
const patientData = makePatient();
await listPage.clickCreate();
await listPage.fillCreateForm({
name: patientData.name,
});
await listPage.submitForm();
await expect(async () => {
const found = await listPage.hasPatientInTable(patientData.name);
expect(found).toBeTruthy();
}).toPass({ timeout: 10000 });
const patient = await api.createPatient({ ...patientData, name: `${patientData.name}_detail` });
cleanup.push(() => api.deletePatient(patient.id, patient.version));
const detailPage = new PatientDetailPage(page);
await detailPage.goto(patient.id);
const name = await detailPage.getPatientName();
expect(name.length).toBeGreaterThan(0);
const assignBtn = page.locator('button:has-text("分配医生")');
if (await assignBtn.isVisible().catch(() => false)) {
await detailPage.clickAssignDoctor();
await detailPage.selectDoctor(doctorData.name);
await detailPage.confirmAssign();
}
});
});

View File

@@ -0,0 +1,37 @@
// apps/web/e2e/flows/vital-signs-flow.spec.ts
import { test, expect } from '../fixtures/auth.fixture';
import { PatientDetailPage } from '../pages/patient-detail.page';
import { makePatient, makeVitalSigns } from '../fixtures/test-data';
test.describe('@flow 体征数据链路', () => {
const cleanup: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const fn of cleanup.reverse()) {
await fn().catch(() => {});
}
cleanup.length = 0;
});
test('API录入体征 → 患者详情查看体征数据列表', async ({ api, authenticatedPage: page }) => {
const patient = await api.createPatient(makePatient());
cleanup.push(() => api.deletePatient(patient.id, patient.version));
const vitalSigns = await api.createVitalSigns(patient.id, makeVitalSigns({
systolic_bp_morning: 130,
heart_rate: 80,
}));
cleanup.push(() => api.deleteVitalSigns(patient.id, vitalSigns.id, vitalSigns.version));
const detailPage = new PatientDetailPage(page);
await detailPage.goto(patient.id);
await detailPage.clickTab('健康数据');
await page.waitForTimeout(800);
await detailPage.clickTab('体征数据');
await page.waitForSelector('.ant-table', { timeout: 10000 });
const rows = await page.locator('.ant-table-tbody tr').count();
expect(rows).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,15 @@
import { test, expect } from '@playwright/test';
test.describe('登录流程', () => {
test('显示登录页面', async ({ page }) => {
await page.goto('/#/login');
await expect(page.locator('.ant-card, .ant-form')).toBeVisible();
});
test('空表单提交显示验证错误', async ({ page }) => {
await page.goto('/#/login');
await page.click('button[type="submit"]');
// Ant Design 应显示验证错误
await expect(page.locator('.ant-form-item-explain-error')).toHaveCount(2); // 用户名 + 密码
});
});

View File

@@ -0,0 +1,56 @@
// apps/web/e2e/pages/appointment.page.ts
import type { Page } from '@playwright/test';
export class AppointmentPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async gotoSchedule() {
await this.page.goto('/#/health/schedules');
await this.page.waitForSelector('.ant-table, .ant-fullcalendar, [class*="calendar"]', { timeout: 10000 });
}
async gotoAppointments() {
await this.page.goto('/#/health/appointments');
await this.page.waitForSelector('.ant-table', { timeout: 10000 });
}
async clickCreateSchedule() {
await this.page.click('button:has-text("新增排班"), button:has-text("创建")');
await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 });
}
async fillScheduleForm(data: { doctor_id?: string; date: string; start_time: string; end_time: string }) {
if (data.doctor_id) {
await this.page.click('.ant-select');
await this.page.click('.ant-select-item-option');
}
await this.page.fill('input[placeholder*="日期"]', data.date);
await this.page.fill('input[placeholder*="开始"]', data.start_time);
await this.page.fill('input[placeholder*="结束"]', data.end_time);
}
async submitScheduleForm() {
await this.page.click('.ant-modal button[type="submit"], .ant-btn-primary');
await this.page.waitForSelector('.ant-message-success', { timeout: 10000 });
}
async clickCreateAppointment() {
await this.page.click('button:has-text("新增预约"), button:has-text("创建")');
await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 });
}
async fillAppointmentForm(data: { patient_id: string; doctor_id: string; date: string; reason?: string }) {
if (data.reason) {
await this.page.fill('textarea, input[placeholder*="原因"]', data.reason);
}
}
async submitAppointmentForm() {
await this.page.click('.ant-modal button[type="submit"], .ant-btn-primary');
await this.page.waitForSelector('.ant-message-success', { timeout: 10000 });
}
}

View File

@@ -0,0 +1,78 @@
// apps/web/e2e/pages/health-data.page.ts
import type { Page, Locator } from '@playwright/test';
export class HealthDataPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async clickAddVitalSigns() {
await this.page.click('button:has-text("录入体征"), button:has-text("新增")');
await this.page.waitForSelector('.ant-modal', { timeout: 5000 });
}
private formField(labelText: string): Locator {
const modal = this.page.locator('.ant-modal');
return modal.locator('.ant-form-item').filter({ hasText: labelText }).locator('input');
}
async fillVitalSignsForm(data: {
record_date?: string;
systolic_bp_morning?: number;
diastolic_bp_morning?: number;
heart_rate?: number;
body_temperature?: number;
spo2?: number;
weight?: number;
blood_sugar?: number;
}) {
const modal = this.page.locator('.ant-modal');
// Fill date - DatePicker needs special handling
const dateToFill = data.record_date || new Date().toISOString().slice(0, 10);
const datePicker = modal.locator('.ant-form-item').filter({ hasText: '记录日期' }).locator('input');
await datePicker.click();
await datePicker.fill(dateToFill);
await this.page.keyboard.press('Enter');
await this.page.waitForTimeout(300);
if (data.systolic_bp_morning) {
await this.formField('收缩压(晨)').fill(String(data.systolic_bp_morning));
}
if (data.diastolic_bp_morning) {
await this.formField('舒张压(晨)').fill(String(data.diastolic_bp_morning));
}
if (data.heart_rate) {
await this.formField('心率').fill(String(data.heart_rate));
}
if (data.weight) {
await this.formField('体重').fill(String(data.weight));
}
if (data.blood_sugar) {
await this.formField('血糖').fill(String(data.blood_sugar));
}
}
async submitVitalSigns() {
const modal = this.page.locator('.ant-modal');
await modal.locator('.ant-modal-footer button.ant-btn-primary').click();
await this.page.waitForSelector('.ant-message-success', { timeout: 10000 });
}
async getVitalSignsList(): Promise<string[]> {
const rows = this.page.locator('.ant-table-tbody tr');
const count = await rows.count();
const texts: string[] = [];
for (let i = 0; i < count; i++) {
texts.push(await rows.nth(i).textContent() ?? '');
}
return texts;
}
async trendChartIsVisible(): Promise<boolean> {
const chart = this.page.locator('canvas, .recharts-wrapper, [class*="chart"]');
return chart.isVisible();
}
}

View File

@@ -0,0 +1,6 @@
// apps/web/e2e/pages/index.ts
export { LoginPage } from './login.page';
export { PatientListPage } from './patient-list.page';
export { PatientDetailPage } from './patient-detail.page';
export { HealthDataPage } from './health-data.page';
export { AppointmentPage } from './appointment.page';

View File

@@ -0,0 +1,48 @@
// apps/web/e2e/pages/login.page.ts
import type { Page } from '@playwright/test';
export class LoginPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await this.page.goto('/#/login');
await this.page.waitForSelector('.ant-card, .ant-form');
}
async fillUsername(username: string) {
await this.page.fill('input[id="username"], input[placeholder*="用户名"]', username);
}
async fillPassword(password: string) {
await this.page.fill('input[type="password"]', password);
}
async clickSubmit() {
await this.page.click('button[type="submit"]');
}
async login(username: string, password: string) {
await this.goto();
await this.fillUsername(username);
await this.fillPassword(password);
await this.clickSubmit();
}
async getErrorMessage(): Promise<string> {
const el = this.page.locator('.ant-form-item-explain-error, .ant-message-error, .ant-alert-error');
return el.first().textContent() ?? '';
}
async isLoggedIn(): Promise<boolean> {
try {
await this.page.waitForURL('**/#/', { timeout: 5000 });
return true;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,44 @@
// apps/web/e2e/pages/patient-detail.page.ts
import type { Page } from '@playwright/test';
export class PatientDetailPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto(id: string) {
await this.page.goto(`/#/health/patients/${id}`);
await this.page.waitForSelector('.ant-descriptions, .ant-tabs', { timeout: 10000 });
}
async getPatientName(): Promise<string> {
const el = this.page.locator('div[style*="font-weight"]').first();
return el.textContent() ?? '';
}
async clickTab(tabName: string) {
await this.page.click(`.ant-tabs-tab:has-text("${tabName}")`);
await this.page.waitForTimeout(500);
}
async getVitalSignsCount(): Promise<number> {
return this.page.locator('.ant-table-tbody tr').count();
}
async clickAssignDoctor() {
await this.page.click('button:has-text("分配医生")');
await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 });
}
async selectDoctor(doctorName: string) {
await this.page.click('.ant-select');
await this.page.click(`.ant-select-item-option:has-text("${doctorName}")`);
}
async confirmAssign() {
await this.page.click('.ant-modal button[type="submit"], .ant-btn-primary');
await this.page.waitForSelector('.ant-message-success', { timeout: 5000 });
}
}

View File

@@ -0,0 +1,66 @@
// apps/web/e2e/pages/patient-list.page.ts
import type { Page } from '@playwright/test';
export class PatientListPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await this.page.goto('/#/health/patients');
await this.page.waitForSelector('.ant-table', { timeout: 15000 });
}
async clickCreate() {
await this.page.click('button:has-text("新增"), button:has-text("新建"), button:has-text("创建")');
await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 });
}
async fillCreateForm(data: { name: string; gender?: string; birth_date?: string }) {
const drawer = this.page.locator('.ant-drawer');
await drawer.locator('input').first().waitFor({ state: 'visible' });
await this.page.locator('.ant-drawer [name="name"] input, .ant-drawer input').first().fill(data.name);
if (data.gender) {
await drawer.locator('.ant-select').first().click();
await this.page.locator(`.ant-select-item-option:has-text("${data.gender === 'male' ? '男' : '女'}")`).first().click();
}
if (data.birth_date) {
await drawer.locator('[name="birth_date"] input, input[placeholder*="出生"]').fill(data.birth_date);
}
}
async submitForm() {
await this.page.click('.ant-drawer button.ant-btn-primary, button:has-text("保存"), .ant-modal .ant-btn-primary');
await this.page.waitForSelector('.ant-message-success', { timeout: 10000 });
}
async searchPatient(name: string) {
const searchInput = this.page.locator('input[placeholder*="搜索"]').first();
await searchInput.fill(name);
await searchInput.press('Enter');
await this.page.waitForTimeout(1000);
}
async clickPatientRow(row: number) {
const rows = this.page.locator('.ant-table-tbody tr');
await rows.nth(row).click();
}
async clickPatientByName(name: string) {
await this.searchPatient(name);
const row = this.page.locator(`.ant-table-tbody tr:has-text("${name}")`).first();
await row.click();
}
async getTableRowCount(): Promise<number> {
return this.page.locator('.ant-table-tbody tr').count();
}
async hasPatientInTable(name: string): Promise<boolean> {
await this.searchPatient(name);
const count = await this.page.locator(`.ant-table-tbody tr:has-text("${name}")`).count();
return count > 0;
}
}

View File

@@ -0,0 +1,24 @@
import { test, expect } from './auth.fixture';
test.describe('插件管理', () => {
test('插件管理页面加载', async ({ page }) => {
await page.goto('/#/');
// 侧边栏显示"扩展管理插件管理"
await page.locator('text=扩展管理').first().click();
await page.waitForLoadState('networkidle');
// 页面不崩溃
await expect(page.locator('main')).toBeVisible();
});
test('刷新按钮可点击', async ({ page }) => {
await page.goto('/#/');
await page.locator('text=扩展管理').first().click();
await page.waitForLoadState('networkidle');
const refreshBtn = page.locator('button:has-text("刷新")');
if (await refreshBtn.isVisible().catch(() => false)) {
await expect(refreshBtn).toBeEnabled();
await refreshBtn.click();
await expect(page.locator('main')).toBeVisible();
}
});
});

View File

@@ -0,0 +1,15 @@
import { test, expect } from '@playwright/test';
test.describe('登录流程', () => {
test('显示登录页面', async ({ page }) => {
await page.goto('/#/login');
await expect(page.locator('.ant-card, .ant-form')).toBeVisible();
});
test('空表单提交显示验证错误', async ({ page }) => {
await page.goto('/#/login');
await page.click('button[type="submit"]');
// Ant Design 应显示验证错误
await expect(page.locator('.ant-form-item-explain-error')).toHaveCount(2); // 用户名 + 密码
});
});

View File

@@ -0,0 +1,24 @@
import { test, expect } from '../fixtures/auth.fixture';
test.describe('插件管理', () => {
test('插件管理页面加载', async ({ page }) => {
await page.goto('/#/');
// 侧边栏显示"扩展管理插件管理"
await page.locator('text=扩展管理').first().click();
await page.waitForLoadState('networkidle');
// 页面不崩溃
await expect(page.locator('main')).toBeVisible();
});
test('刷新按钮可点击', async ({ page }) => {
await page.goto('/#/');
await page.locator('text=扩展管理').first().click();
await page.waitForLoadState('networkidle');
const refreshBtn = page.locator('button:has-text("刷新")');
if (await refreshBtn.isVisible().catch(() => false)) {
await expect(refreshBtn).toBeEnabled();
await refreshBtn.click();
await expect(page.locator('main')).toBeVisible();
}
});
});

View File

@@ -0,0 +1,39 @@
import { test, expect } from '../fixtures/auth.fixture';
test.describe('多租户隔离', () => {
test('侧边栏按模块分组显示', async ({ page }) => {
await page.goto('/#/');
await page.waitForLoadState('networkidle');
// 验证侧边栏模块分组
await expect(page.locator('text=基础模块').first()).toBeVisible({ timeout: 10000 });
await expect(page.locator('text=业务模块').first()).toBeVisible();
await expect(page.locator('text=系统').first()).toBeVisible();
// 验证关键菜单项
await expect(page.locator('text=工作台').first()).toBeVisible();
await expect(page.locator('text=用户管理').first()).toBeVisible();
});
test('顶部导航栏显示用户信息', async ({ page }) => {
await page.goto('/#/');
await page.waitForLoadState('networkidle');
// 验证顶部导航栏显示管理员信息
await expect(page.locator('text=系统管理员').first()).toBeVisible({ timeout: 10000 });
});
test('页面间导航正常工作', async ({ page }) => {
await page.goto('/#/');
await page.waitForLoadState('networkidle');
// 点击侧边栏的用户管理(精确匹配侧边栏区域)
const sidebar = page.locator('complementary, [class*=sider], [class*=menu], nav').first();
await sidebar.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
// 点击工作台返回
await sidebar.locator('text=工作台').first().click();
await expect(page).toHaveURL(/#\/$/, { timeout: 10000 });
});
});

View File

@@ -0,0 +1,48 @@
import { test, expect } from '../fixtures/auth.fixture';
test.describe('用户管理', () => {
test('用户列表页面加载并显示表格', async ({ page }) => {
await page.goto('/#/');
// 通过侧边栏导航到用户管理
await page.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
// 标题
await expect(page.locator('h4')).toContainText('用户管理');
// 新建用户按钮
await expect(page.locator('button:has-text("新建用户")')).toBeVisible();
// 搜索框
await expect(page.locator('input[placeholder*="搜索"]')).toBeVisible();
// 表格列头
await expect(page.locator('text=用户').first()).toBeVisible();
await expect(page.locator('text=状态').first()).toBeVisible();
});
test('新建用户弹窗表单验证', async ({ page }) => {
await page.goto('/#/');
await page.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
// 点击新建
await page.click('button:has-text("新建用户")');
// 弹窗出现
await expect(page.locator('.ant-modal')).toBeVisible();
// 直接提交应显示验证错误(点击 modal 内最后一个 button 即确认按钮)
const modalButtons = page.locator('.ant-modal .ant-modal-footer button');
await modalButtons.last().click();
// Ant Design 应显示验证错误(用户名 + 密码必填)
await expect(page.locator('.ant-form-item-explain-error')).toHaveCount(2);
// 关闭弹窗(点击第一个按钮即取消)
await modalButtons.first().click();
});
test('搜索框可输入', async ({ page }) => {
await page.goto('/#/');
await page.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
const searchInput = page.locator('input[placeholder*="搜索"]');
await searchInput.fill('admin');
await expect(searchInput).toHaveValue('admin');
});
});

View File

@@ -0,0 +1,39 @@
import { test, expect } from './auth.fixture';
test.describe('多租户隔离', () => {
test('侧边栏按模块分组显示', async ({ page }) => {
await page.goto('/#/');
await page.waitForLoadState('networkidle');
// 验证侧边栏模块分组
await expect(page.locator('text=基础模块').first()).toBeVisible({ timeout: 10000 });
await expect(page.locator('text=业务模块').first()).toBeVisible();
await expect(page.locator('text=系统').first()).toBeVisible();
// 验证关键菜单项
await expect(page.locator('text=工作台').first()).toBeVisible();
await expect(page.locator('text=用户管理').first()).toBeVisible();
});
test('顶部导航栏显示用户信息', async ({ page }) => {
await page.goto('/#/');
await page.waitForLoadState('networkidle');
// 验证顶部导航栏显示管理员信息
await expect(page.locator('text=系统管理员').first()).toBeVisible({ timeout: 10000 });
});
test('页面间导航正常工作', async ({ page }) => {
await page.goto('/#/');
await page.waitForLoadState('networkidle');
// 点击侧边栏的用户管理(精确匹配侧边栏区域)
const sidebar = page.locator('complementary, [class*=sider], [class*=menu], nav').first();
await sidebar.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
// 点击工作台返回
await sidebar.locator('text=工作台').first().click();
await expect(page).toHaveURL(/#\/$/, { timeout: 10000 });
});
});

View File

@@ -0,0 +1,48 @@
import { test, expect } from './auth.fixture';
test.describe('用户管理', () => {
test('用户列表页面加载并显示表格', async ({ page }) => {
await page.goto('/#/');
// 通过侧边栏导航到用户管理
await page.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
// 标题
await expect(page.locator('h4')).toContainText('用户管理');
// 新建用户按钮
await expect(page.locator('button:has-text("新建用户")')).toBeVisible();
// 搜索框
await expect(page.locator('input[placeholder*="搜索"]')).toBeVisible();
// 表格列头
await expect(page.locator('text=用户').first()).toBeVisible();
await expect(page.locator('text=状态').first()).toBeVisible();
});
test('新建用户弹窗表单验证', async ({ page }) => {
await page.goto('/#/');
await page.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
// 点击新建
await page.click('button:has-text("新建用户")');
// 弹窗出现
await expect(page.locator('.ant-modal')).toBeVisible();
// 直接提交应显示验证错误(点击 modal 内最后一个 button 即确认按钮)
const modalButtons = page.locator('.ant-modal .ant-modal-footer button');
await modalButtons.last().click();
// Ant Design 应显示验证错误(用户名 + 密码必填)
await expect(page.locator('.ant-form-item-explain-error')).toHaveCount(2);
// 关闭弹窗(点击第一个按钮即取消)
await modalButtons.first().click();
});
test('搜索框可输入', async ({ page }) => {
await page.goto('/#/');
await page.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
const searchInput = page.locator('input[placeholder*="搜索"]');
await searchInput.fill('admin');
await expect(searchInput).toHaveValue('admin');
});
});

5980
apps/web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
apps/web/public/crm.wasm Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
apps/web/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

View File

@@ -0,0 +1,349 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.13.6'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}

View File

@@ -0,0 +1,2 @@
User-agent: *
Allow: /

371
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,371 @@
import { useEffect, lazy, Suspense, useMemo } from 'react';
import { HashRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { ConfigProvider, theme as antdTheme, Spin, Result } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import MainLayout from './layouts/MainLayout';
import Login from './pages/Login';
import { ErrorBoundary } from './components/ErrorBoundary';
import { useAuthStore } from './stores/auth';
import { useAppStore } from './stores/app';
import type { ThemeName } from './stores/app';
import { ROUTE_PERMISSIONS, FROZEN_ROUTES, validateRouteCoverage } from './routeConfig';
const Home = lazy(() => import('./pages/Home'));
const Users = lazy(() => import('./pages/Users'));
const Roles = lazy(() => import('./pages/Roles'));
const Organizations = lazy(() => import('./pages/Organizations'));
const Workflow = lazy(() => import('./pages/Workflow'));
const Messages = lazy(() => import('./pages/Messages'));
const Settings = lazy(() => import('./pages/Settings'));
const PluginAdmin = lazy(() => import('./pages/PluginAdmin'));
const PluginMarket = lazy(() => import('./pages/PluginMarket'));
const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage'));
const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => ({ default: m.PluginTabsPage })));
const PluginTreePage = lazy(() => import('./pages/PluginTreePage').then((m) => ({ default: m.PluginTreePage })));
const PluginGraphPage = lazy(() => import('./pages/PluginGraphPage').then((m) => ({ default: m.PluginGraphPage })));
const PluginDashboardPage = lazy(() => import('./pages/PluginDashboardPage').then((m) => ({ default: m.PluginDashboardPage })));
const PluginKanbanPage = lazy(() => import('./pages/PluginKanbanPage'));
// 健康管理模块
const PatientList = lazy(() => import('./pages/health/PatientList'));
const PatientDetail = lazy(() => import('./pages/health/PatientDetail'));
const PatientTagManage = lazy(() => import('./pages/health/PatientTagManage'));
const DoctorList = lazy(() => import('./pages/health/DoctorList'));
const AppointmentList = lazy(() => import('./pages/health/AppointmentList'));
const DoctorSchedule = lazy(() => import('./pages/health/DoctorSchedule'));
const FollowUpTaskList = lazy(() => import('./pages/health/FollowUpTaskList'));
const FollowUpRecordList = lazy(() => import('./pages/health/FollowUpRecordList'));
const ConsultationList = lazy(() => import('./pages/health/ConsultationList'));
const ConsultationDetail = lazy(() => import('./pages/health/ConsultationDetail'));
const PointsRuleList = lazy(() => import('./pages/health/PointsRuleList'));
const PointsProductList = lazy(() => import('./pages/health/PointsProductList'));
const PointsOrderList = lazy(() => import('./pages/health/PointsOrderList'));
const OfflineEventList = lazy(() => import('./pages/health/OfflineEventList'));
const StatisticsDashboard = lazy(() => import('./pages/health/StatisticsDashboard'));
const AiPromptList = lazy(() => import('./pages/health/AiPromptList'));
const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
const AiConfigPage = lazy(() => import('./pages/health/AiConfigPage'));
const KnowledgeV2Page = lazy(() => import('./pages/ai/KnowledgeV2Page'));
const AiChatPage = lazy(() => import('./pages/ai/ChatPage'));
const AlertList = lazy(() => import('./pages/health/AlertList'));
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList'));
const DeviceManage = lazy(() => import('./pages/health/DeviceManage'));
const RealtimeMonitor = lazy(() => import('./pages/health/RealtimeMonitor'));
const OAuthClientList = lazy(() => import('./pages/health/OAuthClientList'));
const DialysisManageList = lazy(() => import('./pages/health/DialysisManageList'));
const ActionInbox = lazy(() => import('./pages/health/ActionInbox'));
const FollowUpTemplateList = lazy(() => import('./pages/health/FollowUpTemplateList'));
const CarePlanList = lazy(() => import('./pages/health/CarePlanList'));
const CarePlanDetail = lazy(() => import('./pages/health/CarePlanDetail'));
const ShiftList = lazy(() => import('./pages/health/ShiftList'));
const ShiftDetail = lazy(() => import('./pages/health/ShiftDetail'));
const MedicationRecordList = lazy(() => import('./pages/health/MedicationRecordList'));
const BleGatewayList = lazy(() => import('./pages/health/BleGatewayList'));
const BleGatewayDetail = lazy(() => import('./pages/health/BleGatewayDetail'));
const CriticalValueThresholdList = lazy(() => import('./pages/health/CriticalValueThresholdList'));
const FamilyProxyPage = lazy(() => import('./pages/health/FamilyProxyPage'));
// 内容管理
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
const ArticleEditor = lazy(() => import('./pages/health/articleEditor/ArticleEditor'));
const ArticleCategoryManage = lazy(() => import('./pages/health/ArticleCategoryManage'));
const ArticleTagManage = lazy(() => import('./pages/health/ArticleTagManage'));
const BannerManage = lazy(() => import('./pages/health/BannerManage'));
const MediaLibrary = lazy(() => import('./pages/health/MediaLibrary'));
function FrozenRoute() {
return <Result status="info" title="功能暂未开放" subTitle="该功能正在优化中,敬请期待" />;
}
function ForbiddenPage() {
const navigate = useNavigate();
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
<Result
status="403"
title="权限不足"
subTitle="您没有访问此页面的权限,请联系管理员"
extra={<button onClick={() => navigate('/')} style={{ cursor: 'pointer', color: 'var(--ant-color-primary)', background: 'none', border: 'none', fontSize: 14 }}></button>}
/>
</div>
);
}
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const permissions = useAuthStore((s) => s.permissions);
const location = useLocation();
if (!isAuthenticated) return <Navigate to="/login" replace />;
const path = location.pathname;
// 冻结路由检查
if (FROZEN_ROUTES.some((frozen) => path.startsWith(frozen))) {
return <FrozenRoute />;
}
// 首页/工作台始终放行
if (path === '/' || path === '') return <>{children}</>;
const matchedPrefix = Object.keys(ROUTE_PERMISSIONS).find(
(prefix) => path === prefix || path.startsWith(prefix + '/'),
);
if (matchedPrefix) {
const required = ROUTE_PERMISSIONS[matchedPrefix];
const hasAccess = required.some((r) => permissions.includes(r));
if (!hasAccess) return <ForbiddenPage />;
} else {
return <ForbiddenPage />;
}
return <>{children}</>;
}
const baseToken = {
borderRadius: 10,
borderRadiusLG: 12,
borderRadiusSM: 6,
fontFamily: "'Noto Sans SC', -apple-system, system-ui, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif",
fontSize: 14,
fontSizeHeading4: 20,
controlHeight: 40,
controlHeightLG: 44,
controlHeightSM: 32,
boxShadow: 'none',
boxShadowSecondary: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
};
const baseComponents = {
Button: { primaryShadow: 'none', fontWeight: 500 },
Card: { paddingLG: 20 },
Menu: { itemBorderRadius: 10, itemMarginInline: 8, itemHeight: 40 },
Modal: { borderRadiusLG: 16 },
Tag: { borderRadiusSM: 6 },
};
const themeConfigs: Record<ThemeName, { token: Record<string, unknown>; components: Record<string, Record<string, unknown>> }> = {
blue: {
token: {
...baseToken,
colorPrimary: '#2563eb',
colorSuccess: '#059669',
colorWarning: '#d97706',
colorError: '#dc2626',
colorInfo: '#0284c7',
colorBgLayout: '#f8fafc',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBorder: '#e2e8f0',
colorBorderSecondary: '#f1f5f9',
},
components: {
...baseComponents,
Table: { headerBg: '#f1f5f9', headerColor: '#475569', rowHoverBg: '#f1f5f9', fontSize: 14 },
},
},
warm: {
token: {
...baseToken,
borderRadius: 12,
borderRadiusLG: 14,
borderRadiusSM: 8,
colorPrimary: '#C4623A',
colorSuccess: '#5B7A5E',
colorWarning: '#C4873A',
colorError: '#B54A4A',
colorInfo: '#8B7A5E',
colorBgLayout: '#F5F0EB',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBorder: '#E8E2DC',
colorBorderSecondary: '#F0EBE5',
},
components: {
...baseComponents,
Table: { headerBg: '#EDE8E2', headerColor: '#7A756E', rowHoverBg: '#F5F0EB', fontSize: 14 },
},
},
dark: {
token: {
...baseToken,
colorPrimary: '#60A5FA',
colorSuccess: '#34D399',
colorWarning: '#FBBF24',
colorError: '#F87171',
colorInfo: '#38BDF8',
colorBgLayout: '#0F172A',
colorBgContainer: '#1E293B',
colorBgElevated: '#334155',
colorBorder: '#334155',
colorBorderSecondary: 'rgba(255,255,255,0.06)',
boxShadow: 'none',
boxShadowSecondary: '0 2px 8px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.2)',
},
components: {
...baseComponents,
Table: { headerBg: '#1E293B', headerColor: '#94A3B8', rowHoverBg: '#1E293B', fontSize: 14 },
},
},
emerald: {
token: {
...baseToken,
borderRadius: 10,
borderRadiusLG: 14,
borderRadiusSM: 8,
colorPrimary: '#5B7A5E',
colorSuccess: '#3D7A42',
colorWarning: '#B8863A',
colorError: '#A54A4A',
colorInfo: '#4A7A8B',
colorBgLayout: '#F4F7F4',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBorder: '#D5DED5',
colorBorderSecondary: '#E5ECE5',
},
components: {
...baseComponents,
Table: { headerBg: '#EDF2ED', headerColor: '#5A6E5A', rowHoverBg: '#F4F7F4', fontSize: 14 },
},
},
};
export default function App() {
const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
const themeName = useAppStore((s) => s.theme);
useEffect(() => {
loadFromStorage();
}, [loadFromStorage]);
useEffect(() => {
document.documentElement.setAttribute('data-theme', themeName);
}, [themeName]);
// DEV mode: validate all routes have permission declarations
useEffect(() => {
validateRouteCoverage([
"/users", "/roles", "/organizations", "/workflow", "/messages", "/settings",
"/plugins/admin", "/plugins/market",
"/health/statistics", "/health/patients", "/health/tags", "/health/doctors",
"/health/appointments", "/health/schedules", "/health/follow-up-tasks",
"/health/follow-up-records", "/health/consultations",
"/health/points-rules", "/health/points-products", "/health/points-orders",
"/health/offline-events", "/health/ai-prompts", "/health/ai-analysis",
"/health/ai-usage", "/health/ai-config", "/health/ai-knowledge", "/health/alerts", "/health/alert-dashboard",
"/ai/chat",
"/health/alert-rules", "/health/devices", "/health/realtime-monitor",
"/health/oauth-clients", "/health/dialysis", "/health/action-inbox",
"/health/follow-up-templates", "/health/care-plans", "/health/shifts",
"/health/medications", "/health/ble-gateways",
"/health/critical-value-thresholds", "/health/diagnoses",
"/health/family-proxy", "/health/consents",
"/health/articles", "/health/article-categories", "/health/article-tags",
"/health/banners", "/health/media-library",
"/health/medication-records",
]);
}, []);
const isDark = themeName === 'dark';
const antTheme = useMemo(() => themeConfigs[themeName] ?? themeConfigs.blue, [themeName]);
return (
<>
<a href="#root" className="erp-skip-link"></a>
<ConfigProvider
locale={zhCN}
theme={{
...antTheme,
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
}}
>
<HashRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<PrivateRoute>
<MainLayout>
<ErrorBoundary>
<Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}><Spin size="large" /></div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/users" element={<Users />} />
<Route path="/roles" element={<Roles />} />
<Route path="/organizations" element={<Organizations />} />
<Route path="/workflow" element={<Workflow />} />
<Route path="/messages" element={<Messages />} />
<Route path="/settings" element={<Settings />} />
<Route path="/plugins/admin" element={<PluginAdmin />} />
<Route path="/plugins/market" element={<PluginMarket />} />
<Route path="/plugins/:pluginId/tabs/:pageLabel" element={<PluginTabsPage />} />
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
<Route path="/plugins/:pluginId/graph/:entityName" element={<PluginGraphPage />} />
<Route path="/plugins/:pluginId/dashboard" element={<PluginDashboardPage />} />
<Route path="/plugins/:pluginId/kanban/:entityName" element={<PluginKanbanPage />} />
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
{/* 健康管理 */}
<Route path="/health/statistics" element={<StatisticsDashboard />} />
<Route path="/health/patients" element={<PatientList />} />
<Route path="/health/patients/:id" element={<PatientDetail />} />
<Route path="/health/tags" element={<PatientTagManage />} />
<Route path="/health/doctors" element={<DoctorList />} />
<Route path="/health/appointments" element={<AppointmentList />} />
<Route path="/health/schedules" element={<DoctorSchedule />} />
<Route path="/health/follow-up-tasks" element={<FollowUpTaskList />} />
<Route path="/health/follow-up-records" element={<FollowUpRecordList />} />
<Route path="/health/consultations" element={<ConsultationList />} />
<Route path="/health/consultations/:id" element={<ConsultationDetail />} />
<Route path="/health/points-rules" element={<PointsRuleList />} />
<Route path="/health/points-products" element={<PointsProductList />} />
<Route path="/health/points-orders" element={<PointsOrderList />} />
<Route path="/health/offline-events" element={<OfflineEventList />} />
<Route path="/health/ai-prompts" element={<AiPromptList />} />
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
<Route path="/health/ai-config" element={<AiConfigPage />} />
<Route path="/health/ai-knowledge" element={<KnowledgeV2Page />} />
<Route path="/ai/chat" element={<AiChatPage />} />
<Route path="/health/alerts" element={<AlertList />} />
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
<Route path="/health/alert-rules" element={<AlertRuleList />} />
<Route path="/health/devices" element={<DeviceManage />} />
<Route path="/health/realtime-monitor" element={<RealtimeMonitor />} />
<Route path="/health/oauth-clients" element={<OAuthClientList />} />
<Route path="/health/dialysis" element={<DialysisManageList />} />
<Route path="/health/action-inbox" element={<ActionInbox />} />
<Route path="/health/follow-up-templates" element={<FollowUpTemplateList />} />
<Route path="/health/care-plans" element={<CarePlanList />} />
<Route path="/health/care-plans/:id" element={<CarePlanDetail />} />
<Route path="/health/shifts" element={<ShiftList />} />
<Route path="/health/shifts/:id" element={<ShiftDetail />} />
<Route path="/health/medications" element={<MedicationRecordList />} />
<Route path="/health/ble-gateways" element={<BleGatewayList />} />
<Route path="/health/ble-gateways/:id" element={<BleGatewayDetail />} />
<Route path="/health/critical-value-thresholds" element={<CriticalValueThresholdList />} />
<Route path="/health/family-proxy" element={<FamilyProxyPage />} />
{/* 内容管理 */}
<Route path="/health/articles" element={<ArticleManageList />} />
<Route path="/health/articles/new" element={<ArticleEditor />} />
<Route path="/health/articles/:id/edit" element={<ArticleEditor />} />
<Route path="/health/article-categories" element={<ArticleCategoryManage />} />
<Route path="/health/article-tags" element={<ArticleTagManage />} />
<Route path="/health/banners" element={<BannerManage />} />
<Route path="/health/media-library" element={<MediaLibrary />} />
</Routes>
</Suspense>
</ErrorBoundary>
</MainLayout>
</PrivateRoute>
}
/>
</Routes>
</HashRouter>
</ConfigProvider>
</>
);
}

View File

@@ -0,0 +1,127 @@
/**
* AI 模块 API 契约测试analysis + prompts + suggestions + usage
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { analysisApi } from './analysis'
import { promptApi } from './prompts'
import { suggestionApi } from './suggestions'
import { usageApi } from './usage'
beforeEach(() => {
vi.clearAllMocks()
})
describe('analysisApi', () => {
const fakeRes = { data: { data: {} } }
it('list 应调用 GET /ai/analysis/history 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await analysisApi.list({ patient_id: 'p-001', analysis_type: 'lab-report', page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith('/ai/analysis/history', {
params: { patient_id: 'p-001', analysis_type: 'lab-report', page: 1, page_size: 10 },
})
})
it('get 应调用 GET /ai/analysis/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await analysisApi.get('ana-001')
expect(mockGet).toHaveBeenCalledWith('/ai/analysis/ana-001')
})
})
describe('promptApi', () => {
const fakeRes = { data: { data: {} } }
it('list 应调用 GET /ai/prompts 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await promptApi.list({ category: 'analysis', page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith('/ai/prompts', {
params: { category: 'analysis', page: 1, page_size: 10 },
})
})
it('create 应调用 POST /ai/prompts 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '化验解读', system_prompt: '你是专业医生', user_prompt_template: '解读: {report}', model_config: {}, category: 'analysis' }
await promptApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/ai/prompts', req)
})
it('activate 应调用 POST /ai/prompts/:id/activate', async () => {
mockPost.mockResolvedValue(fakeRes)
await promptApi.activate('prompt-001')
expect(mockPost).toHaveBeenCalledWith('/ai/prompts/prompt-001/activate')
})
it('rollback 应调用 POST /ai/prompts/:id/rollback', async () => {
mockPost.mockResolvedValue(fakeRes)
await promptApi.rollback('prompt-001')
expect(mockPost).toHaveBeenCalledWith('/ai/prompts/prompt-001/rollback')
})
})
describe('suggestionApi', () => {
const fakeRes = { data: { data: {} } }
it('list 应调用 GET /ai/suggestions 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await suggestionApi.list({ analysis_id: 'ana-001', status: 'pending' })
expect(mockGet).toHaveBeenCalledWith('/ai/suggestions', {
params: { analysis_id: 'ana-001', status: 'pending' },
})
})
it('approve 应调用 POST /ai/suggestions/:id/approve 并传递 action', async () => {
mockPost.mockResolvedValue(fakeRes)
await suggestionApi.approve('sug-001', 'approve')
expect(mockPost).toHaveBeenCalledWith('/ai/suggestions/sug-001/approve', { action: 'approve' })
})
it('getComparison 应调用 GET /ai/suggestions/:id/comparison', async () => {
mockGet.mockResolvedValue(fakeRes)
await suggestionApi.getComparison('sug-001')
expect(mockGet).toHaveBeenCalledWith('/ai/suggestions/sug-001/comparison')
})
})
describe('usageApi', () => {
const fakeRes = { data: { data: {} } }
it('overview 应调用 GET /ai/usage/overview', async () => {
mockGet.mockResolvedValue(fakeRes)
await usageApi.overview()
expect(mockGet).toHaveBeenCalledWith('/ai/usage/overview')
})
it('byType 应调用 GET /ai/usage/by-type', async () => {
mockGet.mockResolvedValue(fakeRes)
await usageApi.byType()
expect(mockGet).toHaveBeenCalledWith('/ai/usage/by-type')
})
})

View File

@@ -0,0 +1,47 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
export interface AnalysisItem {
id: string;
patient_id: string;
patient_name?: string;
analysis_type: string;
source_ref: string;
model_used: string;
status: string;
result_content: string | null;
result_metadata: Record<string, unknown> | null;
error_message: string | null;
created_at: string;
updated_at: string;
}
export interface HealthSummaryResponse {
patient_id: string;
risk_level: 'low' | 'medium' | 'high' | 'critical';
active_insights_count: number;
recent_analyses_count: number;
latest_insight_title: string | null;
latest_analysis_type: string | null;
summary_items: Array<{
category: string;
title: string;
severity: string | null;
created_at: string;
}>;
}
export const analysisApi = {
list: async (params?: { patient_id?: string; analysis_type?: string; page?: number; page_size?: number }) => {
const resp = await client.get('/ai/analysis/history', { params });
return resp.data.data as PaginatedResponse<AnalysisItem>;
},
get: async (id: string) => {
const resp = await client.get(`/ai/analysis/${id}`);
return resp.data.data as AnalysisItem;
},
getHealthSummary: async (patientId: string) => {
const resp = await client.get('/ai/health-summary', { params: { patient_id: patientId } });
return resp.data.data as HealthSummaryResponse;
},
};

View File

@@ -0,0 +1,98 @@
export type AnalysisType = 'lab-report' | 'trends' | 'checkup-plan' | 'report-summary' | 'follow-up-summary';
interface AnalyzeBody {
report_id?: string;
patient_id?: string;
metrics?: string[];
source_id?: string;
}
const ENDPOINT_MAP: Record<AnalysisType, string> = {
'lab-report': '/ai/analyze/lab-report',
'trends': '/ai/analyze/trends',
'checkup-plan': '/ai/analyze/checkup-plan',
'report-summary': '/ai/analyze/report-summary',
'follow-up-summary': '/ai/analyze/follow-up-summary',
};
export interface SseCallbacks {
onChunk: (content: string, index: number) => void;
onError: (message: string) => void;
onDone: (analysisId: string) => void;
}
export async function startAnalysis(
type: AnalysisType,
body: AnalyzeBody,
callbacks: SseCallbacks,
): Promise<AbortController> {
const controller = new AbortController();
const endpoint = ENDPOINT_MAP[type];
const token = localStorage.getItem('hms-token');
const resp = await fetch(`/api/v1${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ message: '分析请求失败' }));
callbacks.onError(err?.message || `HTTP ${resp.status}`);
return controller;
}
const reader = resp.body?.getReader();
if (!reader) {
callbacks.onError('无法读取响应流');
return controller;
}
const decoder = new TextDecoder();
let chunkIndex = 0;
let buffer = '';
(async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
continue;
}
try {
const event = JSON.parse(data);
if (event.type === 'chunk' && event.content) {
callbacks.onChunk(event.content, chunkIndex++);
} else if (event.type === 'done' && event.analysis_id) {
callbacks.onDone(event.analysis_id);
} else if (event.type === 'error') {
callbacks.onError(event.message || '分析出错');
}
} catch {
// 非 JSON 行,跳过
}
}
}
}
} catch (err) {
if (!controller.signal.aborted) {
callbacks.onError(err instanceof Error ? err.message : '连接中断');
}
}
})();
return controller;
}

118
apps/web/src/api/ai/chat.ts Normal file
View File

@@ -0,0 +1,118 @@
import client from '../client';
export interface ChatHistoryItem {
role: 'user' | 'assistant';
content: string;
}
export type DisplayHint =
| {
type: 'vital_card';
indicator_type: string;
values: [string, number][];
unit: string;
}
| {
type: 'lab_report_card';
report_date: string;
abnormal_count: number;
}
| {
type: 'action_confirm';
action_type: string;
summary: string;
confirm_payload: unknown;
}
| {
type: 'risk_alert';
level: string;
message: string;
}
| {
type: 'trend_chart';
metrics: string[];
period: string;
summary: string;
}
| {
type: 'insight_card';
title: string;
severity: string;
items: string[];
}
| {
type: 'patient_profile';
chronic_conditions: string[];
medication_count: number;
}
| { type: 'text' };
export interface ChatResponse {
reply: string;
message_id: string;
iterations: number;
display_hints?: DisplayHint[];
}
export interface ChatSession {
id: string;
title: string | null;
patient_id: string | null;
status: string;
created_at: string;
updated_at: string;
}
export const aiChatApi = {
sendMessage: async (
message: string,
history: ChatHistoryItem[],
patientId?: string,
sessionId?: string
): Promise<ChatResponse> => {
const resp = await client.post('/ai/chat', {
message,
history,
...(patientId ? { patient_id: patientId } : {}),
...(sessionId ? { session_id: sessionId } : {}),
});
return resp.data.data as ChatResponse;
},
createSession: async (
patientId?: string,
title?: string
): Promise<ChatSession> => {
const resp = await client.post('/ai/chat/sessions', {
...(patientId ? { patient_id: patientId } : {}),
...(title ? { title } : {}),
});
return resp.data.data as ChatSession;
},
listSessions: async (): Promise<ChatSession[]> => {
const resp = await client.get('/ai/chat/sessions');
return resp.data.data as ChatSession[];
},
renameSession: async (
sessionId: string,
title: string
): Promise<void> => {
await client.put(`/ai/chat/sessions/${sessionId}/rename`, { title });
},
closeSession: async (sessionId: string): Promise<void> => {
await client.post(`/ai/chat/sessions/${sessionId}/close`);
},
getSessionMessages: async (sessionId: string): Promise<Array<{
id: string;
role: string;
content: string | null;
created_at: string;
}>> => {
const resp = await client.get(`/ai/chat/sessions/${sessionId}/messages`);
return resp.data.data;
},
};

View File

@@ -0,0 +1,45 @@
import client from '../client';
export interface AiAgentConfig {
model: string;
temperature: number;
max_tokens: number;
max_iterations: number;
system_prompt: string;
}
export interface AiAnalysisDefaults {
model: string;
temperature: number;
max_tokens: number;
}
export interface AiProviderConfig {
provider_type: string;
enabled: boolean;
base_url: string;
api_key: string;
model: string;
}
export interface AiConfig {
agent: AiAgentConfig;
analysis_defaults: AiAnalysisDefaults;
default_provider: string;
providers: Record<string, AiProviderConfig>;
}
export const aiConfigApi = {
get: async () => {
const resp = await client.get('/ai/config');
return resp.data.data as AiConfig;
},
getDefaults: async () => {
const resp = await client.get('/ai/config/defaults');
return resp.data.data as AiConfig;
},
update: async (config: AiConfig) => {
const resp = await client.put('/ai/config', { config });
return resp.data.data as AiConfig;
},
};

View File

@@ -0,0 +1,23 @@
import client from '../client';
export interface DialysisRiskRequest {
patient_id: string;
dialysis_session_id?: string;
}
export interface DialysisRiskAssessment {
id: string;
patient_id: string;
risk_level: string;
risk_factors: string[];
recommendations: string[];
kdigo_stage?: string;
created_at: string;
}
export const dialysisRiskApi = {
assess: async (data: DialysisRiskRequest) => {
const resp = await client.post('/ai/dialysis/risk-assessment', data);
return resp.data.data as DialysisRiskAssessment;
},
};

View File

@@ -0,0 +1,188 @@
import client from '../client';
// === Types ===
export interface KnowledgeBase {
id: string;
tenant_id: string;
name: string;
kb_type: string;
description: string | null;
icon: string | null;
chunk_strategy: Record<string, unknown>;
intent_keywords: Record<string, unknown>;
embedding_model: string | null;
is_enabled: boolean;
document_count: number;
chunk_count: number;
created_at: string;
updated_at: string;
}
export interface KnowledgeDocument {
id: string;
tenant_id: string;
knowledge_base_id: string;
title: string;
doc_type: string;
source_type: string;
source_url: string | null;
file_name: string | null;
file_size: number | null;
file_mime_type: string | null;
content: string | null;
status: string;
chunk_count: number;
embedded_count: number;
error_message: string | null;
processing_started_at: string | null;
processing_completed_at: string | null;
created_at: string;
updated_at: string;
}
export interface SearchHit {
chunk_id: string;
document_id: string;
chunk_index: number;
content: string;
doc_title: string;
similarity: number;
metadata: Record<string, unknown>;
}
export interface CreateKnowledgeBaseReq {
name: string;
kb_type: string;
description?: string;
icon?: string;
chunk_strategy?: Record<string, unknown>;
intent_keywords?: Record<string, unknown>;
embedding_model?: string;
is_enabled?: boolean;
}
export interface UpdateKnowledgeBaseReq {
name?: string;
kb_type?: string;
description?: string;
icon?: string;
chunk_strategy?: Record<string, unknown>;
intent_keywords?: Record<string, unknown>;
embedding_model?: string;
is_enabled?: boolean;
}
export interface CreateDocumentReq {
kb_id: string;
title: string;
doc_type?: string;
source_type?: string;
source_url?: string;
content?: string;
}
// === API ===
export const knowledgeV2Api = {
// Knowledge Bases
listKnowledgeBases: async (params?: {
kb_type?: string;
is_enabled?: boolean;
page?: number;
page_size?: number;
}) => {
const resp = await client.get('/ai/knowledge-bases', { params });
return resp.data.data as {
data: KnowledgeBase[];
total: number;
page: number;
page_size: number;
};
},
getKnowledgeBase: async (id: string) => {
const resp = await client.get(`/ai/knowledge-bases/${id}`);
return resp.data.data as KnowledgeBase;
},
createKnowledgeBase: async (data: CreateKnowledgeBaseReq) => {
const resp = await client.post('/ai/knowledge-bases', data);
return resp.data.data as { id: string };
},
updateKnowledgeBase: async (id: string, data: UpdateKnowledgeBaseReq) => {
const resp = await client.put(`/ai/knowledge-bases/${id}`, data);
return resp.data.data as { id: string };
},
deleteKnowledgeBase: async (id: string) => {
const resp = await client.delete(`/ai/knowledge-bases/${id}`);
return resp.data.data as { id: string };
},
// Documents
listDocuments: async (
kbId: string,
params?: { status?: string; page?: number; page_size?: number },
) => {
const resp = await client.get(
`/ai/knowledge-bases/${kbId}/documents`,
{ params },
);
return resp.data.data as {
data: KnowledgeDocument[];
total: number;
page: number;
page_size: number;
};
},
getDocument: async (id: string) => {
const resp = await client.get(`/ai/documents/${id}`);
return resp.data.data as KnowledgeDocument;
},
createManualDocument: async (data: CreateDocumentReq) => {
const resp = await client.post('/ai/documents/manual', data);
return resp.data.data as { id: string };
},
uploadDocument: async (
kbId: string,
file: File,
title?: string,
) => {
const formData = new FormData();
formData.append('kb_id', kbId);
formData.append('file', file);
if (title) {
formData.append('title', title);
}
const resp = await client.post('/ai/documents/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return resp.data.data as { id: string };
},
deleteDocument: async (kbId: string, id: string) => {
const resp = await client.delete(
`/ai/knowledge-bases/${kbId}/documents/${id}`,
);
return resp.data.data as { id: string };
},
// Hit Test
hitTest: async (kbId: string, query: string, topK?: number) => {
const resp = await client.post('/ai/documents/hit-test', {
kb_id: kbId,
query,
top_k: topK,
});
return resp.data.data as {
query: string;
total: number;
hits: SearchHit[];
};
},
};

View File

@@ -0,0 +1,54 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
export interface PromptItem {
id: string;
name: string;
description: string;
system_prompt: string;
user_prompt_template: string;
model_config: Record<string, unknown>;
version: number;
is_active: boolean;
category: string;
analysis_type: string;
tags: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
export interface CreatePromptReq {
name: string;
description?: string;
system_prompt: string;
user_prompt_template: string;
model_config: Record<string, unknown>;
category: string;
analysis_type: string;
}
export const promptApi = {
list: async (params?: { category?: string; analysis_type?: string; page?: number; page_size?: number }) => {
const resp = await client.get('/ai/prompts', { params });
return resp.data.data as PaginatedResponse<PromptItem>;
},
create: async (data: CreatePromptReq) => {
const resp = await client.post('/ai/prompts', data);
return resp.data.data as PromptItem;
},
activate: async (id: string) => {
const resp = await client.post(`/ai/prompts/${id}/activate`);
return resp.data.data as PromptItem;
},
deactivate: async (id: string) => {
const resp = await client.post(`/ai/prompts/${id}/deactivate`);
return resp.data.data as PromptItem;
},
rollback: async (id: string) => {
const resp = await client.post(`/ai/prompts/${id}/rollback`);
return resp.data.data as PromptItem;
},
delete: async (id: string) => {
await client.delete(`/ai/prompts/${id}`);
},
};

View File

@@ -0,0 +1,38 @@
import client from '../client';
export interface SuggestionItem {
id: string;
analysis_id: string;
suggestion_type: string;
risk_level: string;
params: Record<string, unknown> | null;
status: string;
created_at: string;
}
export interface ComparisonReport {
suggestion_id: string;
baseline: Record<string, unknown> | null;
current: Record<string, unknown> | null;
comparison_available: boolean;
message?: string;
}
export const suggestionApi = {
list: async (params?: { analysis_id?: string; status?: string }) => {
const resp = await client.get('/ai/suggestions', { params });
return resp.data.data as { data: SuggestionItem[]; total: number };
},
approve: async (id: string, action: 'approve' | 'reject') => {
const resp = await client.post(`/ai/suggestions/${id}/approve`, { action });
return resp.data.data as { id: string; status: string };
},
execute: async (id: string) => {
const resp = await client.post(`/ai/suggestions/${id}/execute`);
return resp.data.data as { id: string; status: string };
},
getComparison: async (id: string) => {
const resp = await client.get(`/ai/suggestions/${id}/comparison`);
return resp.data.data as ComparisonReport;
},
};

View File

@@ -0,0 +1,107 @@
import client from '../client';
export interface UsageOverview {
total_count: number;
}
export interface TypeDistribution {
analysis_type: string;
count: number;
}
export interface ProviderInfo {
id: string;
name: string;
provider_type: string;
is_active: boolean;
model_name?: string;
}
export interface ProviderHealth {
provider_id: string;
status: string;
latency_ms?: number;
last_checked_at?: string;
}
export interface QuotaSummary {
provider_id: string;
quota_limit: number;
quota_used: number;
quota_remaining: number;
period: string;
}
export interface BudgetStatus {
total_budget: number;
spent: number;
remaining: number;
period: string;
}
export interface CostEstimate {
analysis_type: string;
estimated_cost: number;
currency: string;
}
export interface DailyUsageRow {
date: string;
feature: string;
provider: string;
model: string;
total_calls: number;
total_input_tokens: number;
total_output_tokens: number;
total_cost_cents: number;
}
export interface FeatureFlag {
feature: string;
is_enabled: boolean;
}
export const usageApi = {
overview: async () => {
const resp = await client.get('/ai/usage/overview');
return resp.data.data as UsageOverview;
},
byType: async () => {
const resp = await client.get('/ai/usage/by-type');
return resp.data.data as TypeDistribution[];
},
listProviders: async () => {
const resp = await client.get('/ai/providers');
return resp.data.data as ProviderInfo[];
},
getProvidersHealth: async () => {
const resp = await client.get('/ai/providers/health');
return resp.data.data as ProviderHealth[];
},
getQuotaSummary: async () => {
const resp = await client.get('/ai/quota/summary');
return resp.data.data as QuotaSummary[];
},
getBudgetStatus: async () => {
const resp = await client.get('/ai/budget/status');
return resp.data.data as BudgetStatus;
},
getCostEstimate: async (params: { analysis_type: string }) => {
const resp = await client.get('/ai/cost/estimate', { params });
return resp.data.data as CostEstimate;
},
getDailyUsage: async (startDate: string, endDate: string) => {
const resp = await client.get('/ai/admin/daily-usage', {
params: { start_date: startDate, end_date: endDate },
});
return resp.data.data as DailyUsageRow[];
},
getFeatureFlags: async () => {
const resp = await client.get('/ai/admin/flags');
return resp.data.data as FeatureFlag[];
},
updateFeatureFlag: async (feature: string, enabled: boolean) => {
const resp = await client.post('/ai/admin/flags', { feature, enabled });
return resp.data.data as { feature: string; enabled: boolean };
},
};

View File

@@ -0,0 +1,51 @@
/**
* auditLogs API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('./client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import * as auditLogsApi from './auditLogs'
beforeEach(() => {
vi.clearAllMocks()
})
describe('auditLogs API', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listAuditLogs 应调用 GET /audit-logs 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await auditLogsApi.listAuditLogs({ resource_type: 'user', user_id: 'u-001', page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith('/audit-logs', {
params: expect.objectContaining({
resource_type: 'user',
user_id: 'u-001',
page: 1,
page_size: 10,
}),
})
})
it('listAuditLogs 默认应传 page=1 page_size=20', async () => {
mockGet.mockResolvedValue(fakeRes)
await auditLogsApi.listAuditLogs()
expect(mockGet).toHaveBeenCalledWith('/audit-logs', {
params: expect.objectContaining({ page: 1, page_size: 20 }),
})
})
})

View File

@@ -0,0 +1,31 @@
import client from './client';
import type { PaginatedResponse } from './types';
export interface AuditLogItem {
id: string;
tenant_id: string;
action: string;
resource_type: string;
resource_id: string;
user_id: string;
old_value?: string;
new_value?: string;
ip_address?: string;
user_agent?: string;
created_at: string;
}
export interface AuditLogQuery {
resource_type?: string;
user_id?: string;
page?: number;
page_size?: number;
}
export async function listAuditLogs(query: AuditLogQuery = {}) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<AuditLogItem> }>(
'/audit-logs',
{ params: { page: query.page ?? 1, page_size: query.page_size ?? 20, ...query } },
);
return data.data;
}

View File

@@ -0,0 +1,55 @@
/**
* auth API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('./client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import * as authApi from './auth'
beforeEach(() => {
vi.clearAllMocks()
})
describe('auth API', () => {
const fakeRes = { data: { success: true, data: {} } }
it('login 应调用 POST /auth/login 并传递用户名密码', async () => {
mockPost.mockResolvedValue(fakeRes)
await authApi.login({ username: 'admin', password: '123456' })
expect(mockPost).toHaveBeenCalledWith('/auth/login', {
username: 'admin',
password: '123456',
})
})
it('logout 应调用 POST /auth/logout', async () => {
mockPost.mockResolvedValue(undefined)
await authApi.logout()
expect(mockPost).toHaveBeenCalledWith('/auth/logout')
})
it('changePassword 应调用 POST /auth/change-password', async () => {
mockPost.mockResolvedValue(undefined)
await authApi.changePassword('oldPass', 'newPass')
expect(mockPost).toHaveBeenCalledWith('/auth/change-password', {
current_password: 'oldPass',
new_password: 'newPass',
})
})
})

55
apps/web/src/api/auth.ts Normal file
View File

@@ -0,0 +1,55 @@
import client from './client';
export interface LoginRequest {
username: string;
password: string;
}
export interface UserInfo {
id: string;
username: string;
email?: string;
phone?: string;
display_name?: string;
avatar_url?: string;
status: string;
roles: RoleInfo[];
version: number;
}
export interface RoleInfo {
id: string;
name: string;
code: string;
description?: string;
is_system: boolean;
}
export interface LoginResponse {
access_token: string;
refresh_token: string;
expires_in: number;
user: UserInfo;
}
export async function login(req: LoginRequest): Promise<LoginResponse> {
const { data } = await client.post<{ success: boolean; data: LoginResponse }>(
'/auth/login',
req
);
return data.data;
}
export async function logout(): Promise<void> {
await client.post('/auth/logout');
}
export async function changePassword(
currentPassword: string,
newPassword: string
): Promise<void> {
await client.post('/auth/change-password', {
current_password: currentPassword,
new_password: newPassword,
});
}

261
apps/web/src/api/client.ts Normal file
View File

@@ -0,0 +1,261 @@
import axios from "axios";
import { message as antMessage } from "antd";
// 请求缓存:短时间内相同请求复用结果
interface CacheEntry {
data: unknown;
timestamp: number;
}
const requestCache = new Map<string, CacheEntry>();
const CACHE_TTL = 5000; // 5 秒缓存
function getCacheKey(config: {
url?: string;
params?: unknown;
method?: string;
}): string {
return `${config.method || "get"}:${config.url || ""}:${JSON.stringify(config.params || {})}`;
}
const defaultAdapter = axios.getAdapter(axios.defaults.adapter);
const client = axios.create({
baseURL: "/api/v1",
timeout: 10000,
headers: { "Content-Type": "application/json" },
adapter: (config) => {
// GET 请求检查缓存
if (config.method === "get" && config.url) {
const key = getCacheKey(config);
const entry = requestCache.get(key);
if (entry && Date.now() - entry.timestamp < CACHE_TTL) {
return Promise.resolve({
data: entry.data,
status: 200,
statusText: "OK (cached)",
headers: new axios.AxiosHeaders(),
config,
});
}
}
return defaultAdapter(config);
},
});
// Decode JWT payload without external library
function decodeJwtPayload(
token: string,
): { exp?: number; sub?: string } | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const payload = JSON.parse(
atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")),
);
return payload;
} catch {
return null;
}
}
// Check if token is expired or about to expire (within 30s buffer)
function isTokenExpiringSoon(token: string): boolean {
const payload = decodeJwtPayload(token);
if (!payload?.exp) return true;
return Date.now() / 1000 > payload.exp - 30;
}
// Request interceptor: attach access token + proactive refresh
client.interceptors.request.use(async (config) => {
const token = localStorage.getItem("access_token");
if (token) {
// If token is about to expire, proactively refresh before sending the request
if (isTokenExpiringSoon(token)) {
const refreshToken = localStorage.getItem("refresh_token");
if (refreshToken && !isRefreshing) {
isRefreshing = true;
try {
const { data } = await axios.post("/api/v1/auth/refresh", {
refresh_token: refreshToken,
});
const newAccess = data.data.access_token;
const newRefresh = data.data.refresh_token;
// 验证新 token 的用户身份一致
const currentUserSub = decodeJwtPayload(token)?.sub;
const newTokenSub = decodeJwtPayload(newAccess)?.sub;
if (currentUserSub && newTokenSub && currentUserSub !== newTokenSub) {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
window.location.hash = "/login";
return Promise.reject(new Error("身份验证失败,请重新登录"));
}
localStorage.setItem("access_token", newAccess);
localStorage.setItem("refresh_token", newRefresh);
processQueue(null, newAccess);
config.headers.Authorization = `Bearer ${newAccess}`;
return config;
} catch {
processQueue(new Error("refresh failed"), null);
// Continue with old token, let 401 handler deal with it
} finally {
isRefreshing = false;
}
}
}
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器:缓存 GET 响应 + 自动刷新 token
client.interceptors.response.use(
(response) => {
// 缓存 GET 响应
if (response.config.method === "get" && response.config.url) {
const key = getCacheKey(response.config);
requestCache.set(key, { data: response.data, timestamp: Date.now() });
}
return response;
},
async (error) => {
const originalRequest = error.config;
if (
error.response?.status === 401 &&
!originalRequest._retry &&
!originalRequest.url?.includes("/auth/login")
) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return client(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) throw new Error("No refresh token");
const { data } = await axios.post("/api/v1/auth/refresh", {
refresh_token: refreshToken,
});
const newAccessToken = data.data.access_token;
const newRefreshToken = data.data.refresh_token;
// 验证新 token 的用户身份与当前用户一致,防止并发场景下身份切换
const currentToken = localStorage.getItem("access_token");
const currentUserSub = currentToken
? decodeJwtPayload(currentToken)?.sub
: null;
const newTokenSub = decodeJwtPayload(newAccessToken)?.sub;
if (currentUserSub && newTokenSub && currentUserSub !== newTokenSub) {
// 身份不一致,强制登出
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
window.location.hash = "/login";
return Promise.reject(new Error("身份验证失败,请重新登录"));
}
localStorage.setItem("access_token", newAccessToken);
localStorage.setItem("refresh_token", newRefreshToken);
processQueue(null, newAccessToken);
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return client(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.hash = "/login";
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
},
);
// 全局错误提示(仅对未被组件处理的错误显示)
let globalErrorTimer: ReturnType<typeof setTimeout> | null = null;
function showGlobalError(msg: string) {
// 防止短时间内弹出大量相同提示
if (globalErrorTimer) return;
antMessage.error(msg, 3);
globalErrorTimer = setTimeout(() => {
globalErrorTimer = null;
}, 3000);
}
// 全局错误拦截 — 在响应拦截器之后、组件 catch 之前执行
// 组件可通过 axios config 中设置 skipGlobalError: true 来抑制全局提示
declare module "axios" {
interface AxiosRequestConfig {
skipGlobalError?: boolean;
}
interface InternalAxiosRequestConfig {
skipGlobalError?: boolean;
}
}
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.config?.skipGlobalError) {
return Promise.reject(error);
}
if (!error.response) {
showGlobalError("网络连接异常,请检查网络");
} else if (error.response.status === 403) {
// 403 通常是权限不足,不全局提示 — 组件层通过 AuthButton 已隐藏操作入口
} else if (error.response.status === 404) {
// 404 通常由组件自行处理(如跳转),不全局提示
} else if (error.response.status >= 500) {
showGlobalError("服务器异常,请稍后重试");
}
return Promise.reject(error);
},
);
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: unknown) => void;
}> = [];
function processQueue(error: unknown, token: string | null) {
failedQueue.forEach(({ resolve, reject }) => {
if (token) resolve(token);
else reject(error);
});
failedQueue = [];
}
// 清除缓存(登录/登出时调用)
export function clearApiCache() {
requestCache.clear();
}
// 通用错误处理:提取后端错误消息并展示
export function handleApiError(err: unknown, fallback = "操作失败"): string {
const msg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || fallback;
antMessage.error(msg);
return msg;
}
export default client;

View File

@@ -0,0 +1,197 @@
/**
* config-modules API 契约测试menus + settings + languages + numberingRules + themes
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('./client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import * as menusApi from './menus'
import * as settingsApi from './settings'
import * as languagesApi from './languages'
import * as numberingApi from './numberingRules'
import * as themesApi from './themes'
beforeEach(() => {
vi.clearAllMocks()
})
// ============================================================
// menus
// ============================================================
describe('menus API', () => {
const fakeRes = { data: { success: true, data: {} } }
it('getMenus 应调用 GET /config/menus', async () => {
mockGet.mockResolvedValue(fakeRes)
await menusApi.getMenus()
expect(mockGet).toHaveBeenCalledWith('/config/menus')
})
it('getMenusForUser 应调用 GET /menus/user', async () => {
mockGet.mockResolvedValue(fakeRes)
await menusApi.getMenusForUser()
expect(mockGet).toHaveBeenCalledWith('/menus/user')
})
it('batchSaveMenus 应调用 PUT /config/menus 并传递 menus 数组', async () => {
mockPut.mockResolvedValue(undefined)
const menus = [{ title: '仪表盘', path: '/dashboard' }]
await menusApi.batchSaveMenus(menus)
expect(mockPut).toHaveBeenCalledWith('/config/menus', { menus })
})
it('createMenu 应调用 POST /config/menus', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { title: '新菜单', path: '/new', sort_order: 10 }
await menusApi.createMenu(req)
expect(mockPost).toHaveBeenCalledWith('/config/menus', req)
})
it('deleteMenu 应调用 DELETE /config/menus/:id 并在 body 传递 version', async () => {
mockDelete.mockResolvedValue(undefined)
await menusApi.deleteMenu('menu-001', 3)
expect(mockDelete).toHaveBeenCalledWith('/config/menus/menu-001', { data: { version: 3 } })
})
})
// ============================================================
// settings
// ============================================================
describe('settings API', () => {
const fakeRes = { data: { success: true, data: {} } }
it('getSetting 应调用 GET /config/settings/:key 并传递 scope 参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await settingsApi.getSetting('site.name', 'global', 'org-001')
expect(mockGet).toHaveBeenCalledWith('/config/settings/site.name', {
params: { scope: 'global', scope_id: 'org-001' },
})
})
it('updateSetting 应调用 PUT /config/settings/:key', async () => {
mockPut.mockResolvedValue(fakeRes)
await settingsApi.updateSetting('site.name', '新名称', 1)
expect(mockPut).toHaveBeenCalledWith('/config/settings/site.name', {
setting_value: '新名称',
version: 1,
})
})
it('deleteSetting 应调用 DELETE /config/settings/:key', async () => {
mockDelete.mockResolvedValue(undefined)
await settingsApi.deleteSetting('site.name', 2)
expect(mockDelete).toHaveBeenCalledWith('/config/settings/site.name', { data: { version: 2 } })
})
})
// ============================================================
// languages
// ============================================================
describe('languages API', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listLanguages 应调用 GET /config/languages', async () => {
mockGet.mockResolvedValue(fakeRes)
await languagesApi.listLanguages()
expect(mockGet).toHaveBeenCalledWith('/config/languages')
})
it('updateLanguage 应调用 PUT /config/languages/:code', async () => {
mockPut.mockResolvedValue(fakeRes)
await languagesApi.updateLanguage('zh-CN', { is_active: true, name: '简体中文' })
expect(mockPut).toHaveBeenCalledWith('/config/languages/zh-CN', {
is_active: true,
name: '简体中文',
})
})
})
// ============================================================
// numberingRules
// ============================================================
describe('numberingRules API', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listNumberingRules 应调用 GET /config/numbering-rules', async () => {
mockGet.mockResolvedValue(fakeRes)
await numberingApi.listNumberingRules(1, 10)
expect(mockGet).toHaveBeenCalledWith('/config/numbering-rules', {
params: { page: 1, page_size: 10 },
})
})
it('createNumberingRule 应调用 POST /config/numbering-rules', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '患者编号', code: 'patient', prefix: 'P', seq_length: 6 }
await numberingApi.createNumberingRule(req)
expect(mockPost).toHaveBeenCalledWith('/config/numbering-rules', req)
})
it('updateNumberingRule 应调用 PUT /config/numbering-rules/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { prefix: 'HMS', version: 1 }
await numberingApi.updateNumberingRule('nr-001', req)
expect(mockPut).toHaveBeenCalledWith('/config/numbering-rules/nr-001', req)
})
it('generateNumber 应调用 POST /config/numbering-rules/:id/generate', async () => {
mockPost.mockResolvedValue(fakeRes)
await numberingApi.generateNumber('nr-001')
expect(mockPost).toHaveBeenCalledWith('/config/numbering-rules/nr-001/generate')
})
it('deleteNumberingRule 应调用 DELETE /config/numbering-rules/:id', async () => {
mockDelete.mockResolvedValue(undefined)
await numberingApi.deleteNumberingRule('nr-001', 1)
expect(mockDelete).toHaveBeenCalledWith('/config/numbering-rules/nr-001', { data: { version: 1 } })
})
})
// ============================================================
// themes
// ============================================================
describe('themes API', () => {
const fakeRes = { data: { success: true, data: {} } }
it('getTheme 应调用 GET /config/themes', async () => {
mockGet.mockResolvedValue(fakeRes)
await themesApi.getTheme()
expect(mockGet).toHaveBeenCalledWith('/config/themes')
})
it('updateTheme 应调用 PUT /config/themes', async () => {
mockPut.mockResolvedValue(fakeRes)
const theme = { primary_color: '#1890ff', brand_name: 'HMS' }
await themesApi.updateTheme(theme)
expect(mockPut).toHaveBeenCalledWith('/config/themes', theme)
})
})

View File

@@ -0,0 +1,66 @@
import client from './client';
import type { PaginatedResponse } from './types';
// --- Types ---
export type InsightType = 'risk_score' | 'anomaly' | 'follow_up_hint' | 'consult_hint';
export type InsightSource = 'rule' | 'llm' | 'hybrid';
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical';
export interface MatchedRule {
rule_id: string;
name: string;
score: number;
severity: string;
suggestion?: string;
}
export interface RiskScore {
score: number;
level: RiskLevel;
matched_rules: MatchedRule[];
}
export interface CopilotInsight {
id: string;
patient_id: string;
insight_type: InsightType;
source: InsightSource;
severity: 'info' | 'warning' | 'critical';
title: string;
content: Record<string, unknown>;
rule_matches?: MatchedRule[];
llm_supplement?: string;
created_at: string;
}
// --- API Functions ---
export async function getPatientRisk(patientId: string) {
const { data } = await client.get<{ success: boolean; data: RiskScore }>(`/copilot/patients/${patientId}/risk`);
return data.data;
}
export async function listInsights(params: {
patient_id?: string;
insight_type?: string;
severity?: string;
page?: number;
page_size?: number;
}) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<CopilotInsight> }>('/copilot/insights', { params });
return data.data;
}
export function dismissInsight(id: string) {
return client.post(`/copilot/insights/${id}/dismiss`);
}
export function listAlerts(params?: { severity?: string; page?: number; page_size?: number }) {
return listInsights({ insight_type: 'anomaly', ...params });
}
export async function listRules(params?: { page?: number; page_size?: number }) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<Record<string, unknown>> }>('/copilot/rules', { params });
return data.data;
}

View File

@@ -0,0 +1,96 @@
/**
* dictionaries API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('./client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import * as dictApi from './dictionaries'
beforeEach(() => {
vi.clearAllMocks()
})
describe('dictionaries API', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listDictionaries 应调用 GET /config/dictionaries 并传递分页参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await dictApi.listDictionaries(1, 10)
expect(mockGet).toHaveBeenCalledWith('/config/dictionaries', {
params: { page: 1, page_size: 10 },
})
})
it('createDictionary 应调用 POST /config/dictionaries', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '性别', code: 'gender', description: '性别字典' }
await dictApi.createDictionary(req)
expect(mockPost).toHaveBeenCalledWith('/config/dictionaries', req)
})
it('updateDictionary 应调用 PUT /config/dictionaries/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { name: '性别(更新)', version: 1 }
await dictApi.updateDictionary('dict-001', req)
expect(mockPut).toHaveBeenCalledWith('/config/dictionaries/dict-001', req)
})
it('deleteDictionary 应调用 DELETE /config/dictionaries/:id 并在 body 传递 version', async () => {
mockDelete.mockResolvedValue(undefined)
await dictApi.deleteDictionary('dict-001', 2)
expect(mockDelete).toHaveBeenCalledWith('/config/dictionaries/dict-001', {
data: { version: 2 },
})
})
it('listItemsByCode 应调用 GET /config/dictionaries/items 并传递 code 参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await dictApi.listItemsByCode('gender')
expect(mockGet).toHaveBeenCalledWith('/config/dictionaries/items', {
params: { code: 'gender' },
})
})
it('createDictionaryItem 应调用 POST /config/dictionaries/:id/items', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { label: '男', value: 'male', sort_order: 1 }
await dictApi.createDictionaryItem('dict-001', req)
expect(mockPost).toHaveBeenCalledWith('/config/dictionaries/dict-001/items', req)
})
it('updateDictionaryItem 应调用 PUT /config/dictionaries/:dictId/items/:itemId', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { label: '女', version: 1 }
await dictApi.updateDictionaryItem('dict-001', 'item-001', req)
expect(mockPut).toHaveBeenCalledWith('/config/dictionaries/dict-001/items/item-001', req)
})
it('deleteDictionaryItem 应调用 DELETE /config/dictionaries/:dictId/items/:itemId', async () => {
mockDelete.mockResolvedValue(undefined)
await dictApi.deleteDictionaryItem('dict-001', 'item-001', 1)
expect(mockDelete).toHaveBeenCalledWith('/config/dictionaries/dict-001/items/item-001', {
data: { version: 1 },
})
})
})

View File

@@ -0,0 +1,111 @@
import client from './client';
import type { PaginatedResponse } from './types';
export interface DictionaryItemInfo {
id: string;
dictionary_id: string;
label: string;
value: string;
sort_order: number;
color?: string;
version: number;
}
export interface DictionaryInfo {
id: string;
name: string;
code: string;
description?: string;
items: DictionaryItemInfo[];
version: number;
}
export interface CreateDictionaryRequest {
name: string;
code: string;
description?: string;
}
export interface UpdateDictionaryRequest {
name?: string;
description?: string;
version: number;
}
export async function listDictionaries(page = 1, pageSize = 20) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<DictionaryInfo> }>(
'/config/dictionaries',
{ params: { page, page_size: pageSize } },
);
return data.data;
}
export async function createDictionary(req: CreateDictionaryRequest) {
const { data } = await client.post<{ success: boolean; data: DictionaryInfo }>(
'/config/dictionaries',
req,
);
return data.data;
}
export async function updateDictionary(id: string, req: UpdateDictionaryRequest) {
const { data } = await client.put<{ success: boolean; data: DictionaryInfo }>(
`/config/dictionaries/${id}`,
req,
);
return data.data;
}
export async function deleteDictionary(id: string, version: number) {
await client.delete(`/config/dictionaries/${id}`, { data: { version } });
}
export async function listItemsByCode(code: string) {
const { data } = await client.get<{ success: boolean; data: DictionaryItemInfo[] }>(
'/config/dictionaries/items',
{ params: { code } },
);
return data.data;
}
export interface CreateDictionaryItemRequest {
label: string;
value: string;
sort_order?: number;
color?: string;
}
export interface UpdateDictionaryItemRequest {
label?: string;
value?: string;
sort_order?: number;
color?: string;
version: number;
}
export async function createDictionaryItem(
dictionaryId: string,
req: CreateDictionaryItemRequest,
) {
const { data } = await client.post<{ success: boolean; data: DictionaryItemInfo }>(
`/config/dictionaries/${dictionaryId}/items`,
req,
);
return data.data;
}
export async function updateDictionaryItem(
dictionaryId: string,
itemId: string,
req: UpdateDictionaryItemRequest,
) {
const { data } = await client.put<{ success: boolean; data: DictionaryItemInfo }>(
`/config/dictionaries/${dictionaryId}/items/${itemId}`,
req,
);
return data.data;
}
export async function deleteDictionaryItem(dictionaryId: string, itemId: string, version: number) {
await client.delete(`/config/dictionaries/${dictionaryId}/items/${itemId}`, { data: { version } });
}

View File

@@ -0,0 +1,128 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
export type ActionType = 'ai_suggestion' | 'alert' | 'followup' | 'data_anomaly';
export type ActionPriority = 'urgent' | 'high' | 'medium' | 'low';
export type ActionStatus = 'pending' | 'in_progress' | 'completed' | 'dismissed';
export interface ActionItem {
id: string;
action_type: ActionType;
priority: ActionPriority;
status: ActionStatus;
title: string;
summary: string;
patient_id: string;
patient_name: string;
source_ref: string;
created_at: string;
updated_at: string;
}
export interface ThreadEvent {
step: string;
label: string;
status: ActionStatus;
detail?: string;
timestamp?: string;
operator?: string;
link_to?: string;
}
export interface ActionDefinition {
key: string;
label: string;
variant: 'primary' | 'danger' | 'default';
api_endpoint?: string;
}
export interface ThreadResponse {
action_item: ActionItem;
thread: ThreadEvent[];
available_actions: ActionDefinition[];
}
export interface WorkbenchStats {
total_pending: number;
ai_suggestion_pending: number;
urgent_alerts: number;
followup_due: number;
completion_rate: number | null;
}
export interface NursePatientSummary {
patient_id: string;
patient_name: string;
pending_actions: number;
highest_priority: ActionPriority;
}
export interface TeamMemberOverview {
user_id: string;
name: string;
title: string;
pending_count: number;
completed_count: number;
overdue_count: number;
completion_rate: number;
}
export interface TeamOverview {
members: TeamMemberOverview[];
risk_distribution: {
high: number;
medium: number;
low: number;
};
total_pending: number;
total_completed: number;
}
export const actionInboxApi = {
list: async (params?: {
status?: string;
type?: string;
page?: number;
page_size?: number;
assigned_to_me?: boolean;
patient_id?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<ActionItem>;
}>('/health/action-inbox', { params });
return data.data;
},
getThread: async (sourceRef: string) => {
const { data } = await client.get<{
success: boolean;
data: ThreadResponse;
}>(`/health/action-inbox/${encodeURIComponent(sourceRef)}/thread`);
return data.data;
},
stats: async (params?: { assigned_to_me?: boolean }) => {
const { data } = await client.get<{
success: boolean;
data: WorkbenchStats;
}>('/health/action-inbox/stats', { params });
return data.data;
},
myPatients: async () => {
const { data } = await client.get<{
success: boolean;
data: NursePatientSummary[];
}>('/health/action-inbox/my-patients');
return data.data;
},
team: async () => {
const { data } = await client.get<{
success: boolean;
data: TeamOverview;
}>('/health/action-inbox/team');
return data.data;
},
};

View File

@@ -0,0 +1,100 @@
/**
* alerts API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { alertApi, alertRuleApi } from './alerts'
beforeEach(() => {
vi.clearAllMocks()
})
describe('alertApi', () => {
const fakeRes = { data: { data: {} } }
it('list 应调用 GET /health/alerts 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await alertApi.list({ patient_id: 'p-001', status: 'active', page: 1, page_size: 20 })
expect(mockGet).toHaveBeenCalledWith('/health/alerts', {
params: { patient_id: 'p-001', status: 'active', page: 1, page_size: 20 },
})
})
it('acknowledge 应调用 PUT /health/alerts/:id/acknowledge 并传递 version', async () => {
mockPut.mockResolvedValue(fakeRes)
await alertApi.acknowledge('a-001', 2)
expect(mockPut).toHaveBeenCalledWith('/health/alerts/a-001/acknowledge', { version: 2 })
})
it('dismiss 应调用 PUT /health/alerts/:id/dismiss', async () => {
mockPut.mockResolvedValue(fakeRes)
await alertApi.dismiss('a-001', 1)
expect(mockPut).toHaveBeenCalledWith('/health/alerts/a-001/dismiss', { version: 1 })
})
it('resolve 应调用 PUT /health/alerts/:id/resolve', async () => {
mockPut.mockResolvedValue(fakeRes)
await alertApi.resolve('a-001', 3)
expect(mockPut).toHaveBeenCalledWith('/health/alerts/a-001/resolve', { version: 3 })
})
})
describe('alertRuleApi', () => {
const fakeRes = { data: { data: {} } }
it('list 应调用 GET /health/alert-rules 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await alertRuleApi.list({ device_type: 'blood_pressure', page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith('/health/alert-rules', {
params: { device_type: 'blood_pressure', page: 1, page_size: 10 },
})
})
it('create 应调用 POST /health/alert-rules 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = {
name: '血压偏高告警',
device_type: 'blood_pressure',
condition_type: 'threshold',
condition_params: { field: 'systolic', operator: '>', value: 140 },
severity: 'high',
}
await alertRuleApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/alert-rules', req)
})
it('update 应调用 PUT /health/alert-rules/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { severity: 'critical', version: 1 }
await alertRuleApi.update('rule-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/alert-rules/rule-001', req)
})
it('deactivate 应调用 PUT /health/alert-rules/:id/deactivate', async () => {
mockPut.mockResolvedValue(fakeRes)
await alertRuleApi.deactivate('rule-001', 2)
expect(mockPut).toHaveBeenCalledWith('/health/alert-rules/rule-001/deactivate', { version: 2 })
})
})

View File

@@ -0,0 +1,118 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Alert {
id: string;
patient_id: string;
patient_name?: string;
rule_id: string;
severity: string;
title: string;
detail?: Record<string, unknown>;
status: string;
acknowledged_by?: string;
acknowledged_by_name?: string;
acknowledged_at?: string;
resolved_at?: string;
created_at: string;
version: number;
}
export interface AlertRule {
id: string;
name: string;
description?: string;
device_type: string;
condition_type: string;
condition_params: Record<string, unknown>;
severity: string;
is_active: boolean;
apply_tags?: Record<string, unknown>;
notify_roles: unknown[];
cooldown_minutes: number;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateAlertRuleReq {
name: string;
description?: string;
device_type: string;
condition_type: string;
condition_params: Record<string, unknown>;
severity?: string;
apply_tags?: Record<string, unknown>;
notify_roles?: unknown[];
cooldown_minutes?: number;
}
export interface UpdateAlertRuleReq {
name?: string;
description?: string;
condition_params?: Record<string, unknown>;
severity?: string;
apply_tags?: Record<string, unknown>;
notify_roles?: unknown[];
cooldown_minutes?: number;
version: number;
}
// --- API ---
export const alertApi = {
list: (params?: { patient_id?: string; doctor_id?: string; status?: string; page?: number; page_size?: number }) =>
client.get('/health/alerts', { params }).then((r) => r.data.data as PaginatedResponse<Alert>),
acknowledge: (id: string, version: number) =>
client.put(`/health/alerts/${id}/acknowledge`, { version }).then((r) => r.data.data as Alert),
dismiss: (id: string, version: number) =>
client.put(`/health/alerts/${id}/dismiss`, { version }).then((r) => r.data.data as Alert),
resolve: (id: string, version: number) =>
client.put(`/health/alerts/${id}/resolve`, { version }).then((r) => r.data.data as Alert),
};
export const alertRuleApi = {
list: (params?: { device_type?: string; page?: number; page_size?: number }) =>
client.get('/health/alert-rules', { params }).then((r) => r.data.data as PaginatedResponse<AlertRule>),
create: (data: CreateAlertRuleReq) =>
client.post('/health/alert-rules', data).then((r) => r.data.data as AlertRule),
update: (id: string, data: UpdateAlertRuleReq) =>
client.put(`/health/alert-rules/${id}`, data).then((r) => r.data.data as AlertRule),
deactivate: (id: string, version: number) =>
client.put(`/health/alert-rules/${id}/deactivate`, { version }).then((r) => r.data.data as AlertRule),
};
// --- Critical Alerts API ---
export interface CriticalAlert {
id: string;
patient_id: string;
patient_name?: string;
alert_type: string;
severity: string;
title: string;
detail?: Record<string, unknown>;
status: string;
acknowledged_by?: string;
acknowledged_at?: string;
notes?: string;
created_at: string;
version: number;
}
export const criticalAlertApi = {
list: (params?: { page?: number; page_size?: number }) =>
client.get('/health/critical-alerts', { params }).then((r) => r.data.data as PaginatedResponse<CriticalAlert>),
get: (id: string) =>
client.get(`/health/critical-alerts/${id}`).then((r) => r.data.data as CriticalAlert),
acknowledge: (id: string, req: { notes?: string }) =>
client.post(`/health/critical-alerts/${id}/acknowledge`, req).then((r) => r.data),
};

View File

@@ -0,0 +1,172 @@
/**
* 健康模块新增 API 函数的契约测试
*
* 验证 dialysisApi / pointsAdminApi / healthDataApi 的日常监测与报告审核函数
* 是否调用了正确的 HTTP 方法、URL 路径和参数。
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
// --- Mock axios client ---
// 三个被测文件都 import client from '../client',相对路径一致
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
// 在 mock 生效后导入被测模块
import { dialysisApi } from './dialysis'
import { pointsAdminApi } from './points'
import { healthDataApi } from './healthData'
beforeEach(() => {
vi.clearAllMocks()
})
// ============================================================
// dialysisApi
// ============================================================
describe('dialysisApi', () => {
const fakeResponse = { data: { success: true, data: {} } }
it('listRecords 应调用 GET /health/patients/:id/dialysis-records 并传递分页参数', async () => {
mockGet.mockResolvedValue(fakeResponse)
await dialysisApi.listRecords('p-001', { page: 2, page_size: 20 })
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith(
'/health/patients/p-001/dialysis-records',
{ params: { page: 2, page_size: 20 } },
)
})
it('getRecord 应调用 GET /health/dialysis-records/:id', async () => {
mockGet.mockResolvedValue(fakeResponse)
await dialysisApi.getRecord('rec-123')
expect(mockGet).toHaveBeenCalledWith('/health/dialysis-records/rec-123')
})
it('createRecord 应调用 POST /health/dialysis-records 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeResponse)
const req = { patient_id: 'p-001', dialysis_date: '2026-04-30', dialysis_type: 'hemodialysis' }
await dialysisApi.createRecord(req)
expect(mockPost).toHaveBeenCalledWith('/health/dialysis-records', req)
})
it('updateRecord 应调用 PUT /health/dialysis-records/:id 并传递请求体', async () => {
mockPut.mockResolvedValue(fakeResponse)
const req = { dry_weight: 65.0, version: 3 }
await dialysisApi.updateRecord('rec-123', req)
expect(mockPut).toHaveBeenCalledWith('/health/dialysis-records/rec-123', req)
})
it('deleteRecord 应调用 DELETE /health/dialysis-records/:id 并在 body 中传递 version', async () => {
mockDelete.mockResolvedValue(undefined)
await dialysisApi.deleteRecord('rec-123', 3)
expect(mockDelete).toHaveBeenCalledWith('/health/dialysis-records/rec-123', {
data: { version: 3 },
})
})
it('reviewRecord 应调用 PUT /health/dialysis-records/:id/review', async () => {
mockPut.mockResolvedValue(fakeResponse)
const req = { version: 2, doctor_notes: '指标正常' }
await dialysisApi.reviewRecord('rec-456', req)
expect(mockPut).toHaveBeenCalledWith('/health/dialysis-records/rec-456/review', req)
})
})
// ============================================================
// pointsAdminApi
// ============================================================
describe('pointsAdminApi', () => {
const fakeResponse = { data: { success: true, data: {} } }
it('getPatientAccount 应调用 GET /health/admin/points/patients/:id/account', async () => {
mockGet.mockResolvedValue(fakeResponse)
await pointsAdminApi.getPatientAccount('p-001')
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/patients/p-001/account')
})
it('listPatientTransactions 应调用 GET 并传递分页参数', async () => {
mockGet.mockResolvedValue(fakeResponse)
await pointsAdminApi.listPatientTransactions('p-001', { page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith(
'/health/admin/points/patients/p-001/transactions',
{ params: { page: 1, page_size: 10 } },
)
})
})
// ============================================================
// healthDataApi — 日常监测 + 报告审核
// ============================================================
describe('healthDataApi 日常监测', () => {
const fakeResponse = { data: { success: true, data: {} } }
it('listDailyMonitoring 应调用 GET /health/patients/:id/daily-monitoring 并传递分页参数', async () => {
mockGet.mockResolvedValue(fakeResponse)
await healthDataApi.listDailyMonitoring('p-001', { page: 1, page_size: 15 })
expect(mockGet).toHaveBeenCalledWith(
'/health/patients/p-001/daily-monitoring',
{ params: { page: 1, page_size: 15 } },
)
})
it('createDailyMonitoring 应调用 POST /health/daily-monitoring 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeResponse)
const req = {
patient_id: 'p-001',
record_date: '2026-04-30',
weight: 70.5,
blood_sugar: 5.2,
}
await healthDataApi.createDailyMonitoring(req)
expect(mockPost).toHaveBeenCalledWith('/health/daily-monitoring', req)
})
it('updateDailyMonitoring 应调用 PUT /health/daily-monitoring/:id 并传递请求体', async () => {
mockPut.mockResolvedValue(fakeResponse)
const req = { weight: 71.0, version: 1 }
await healthDataApi.updateDailyMonitoring('dm-123', req)
expect(mockPut).toHaveBeenCalledWith('/health/daily-monitoring/dm-123', req)
})
it('deleteDailyMonitoring 应调用 DELETE /health/daily-monitoring/:id 并在 body 中传递 version', async () => {
mockDelete.mockResolvedValue(undefined)
await healthDataApi.deleteDailyMonitoring('dm-123', 2)
expect(mockDelete).toHaveBeenCalledWith('/health/daily-monitoring/dm-123', {
data: { version: 2 },
})
})
it('reviewLabReport 应调用 PUT /health/patients/:pid/lab-reports/:rid/review', async () => {
mockPut.mockResolvedValue(fakeResponse)
const req = { version: 1, doctor_notes: '指标略有异常,建议复查' }
await healthDataApi.reviewLabReport('p-001', 'lr-456', req)
expect(mockPut).toHaveBeenCalledWith(
'/health/patients/p-001/lab-reports/lr-456/review',
req,
)
})
})

View File

@@ -0,0 +1,106 @@
/**
* appointments API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { appointmentApi } from './appointments'
beforeEach(() => {
vi.clearAllMocks()
})
describe('appointmentApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/appointments 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await appointmentApi.list({ page: 1, page_size: 20, status: 'confirmed', doctor_id: 'd-001' })
expect(mockGet).toHaveBeenCalledWith('/health/appointments', {
params: { page: 1, page_size: 20, status: 'confirmed', doctor_id: 'd-001' },
})
})
it('get 应调用 GET /health/appointments/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await appointmentApi.get('appt-001')
expect(mockGet).toHaveBeenCalledWith('/health/appointments/appt-001')
})
it('create 应调用 POST /health/appointments 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = {
patient_id: 'p-001',
doctor_id: 'd-001',
appointment_date: '2026-05-10',
start_time: '09:00',
end_time: '09:30',
}
await appointmentApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/appointments', req)
})
it('updateStatus 应调用 PUT /health/appointments/:id/status', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { status: 'cancelled', cancel_reason: '时间冲突', version: 2 }
await appointmentApi.updateStatus('appt-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/appointments/appt-001/status', req)
})
it('listSchedules 应调用 GET /health/doctor-schedules 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await appointmentApi.listSchedules({ doctor_id: 'd-001', date: '2026-05-10' })
expect(mockGet).toHaveBeenCalledWith('/health/doctor-schedules', {
params: { doctor_id: 'd-001', date: '2026-05-10' },
})
})
it('createSchedule 应调用 POST /health/doctor-schedules', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = {
doctor_id: 'd-001',
schedule_date: '2026-05-10',
start_time: '08:00',
end_time: '12:00',
max_appointments: 10,
}
await appointmentApi.createSchedule(req)
expect(mockPost).toHaveBeenCalledWith('/health/doctor-schedules', req)
})
it('updateSchedule 应调用 PUT /health/doctor-schedules/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { max_appointments: 15, version: 1 }
await appointmentApi.updateSchedule('sch-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/doctor-schedules/sch-001', req)
})
it('calendar 应调用 GET /health/doctor-schedules/calendar', async () => {
mockGet.mockResolvedValue(fakeRes)
await appointmentApi.calendar({ start_date: '2026-05-01', end_date: '2026-05-31', doctor_id: 'd-001' })
expect(mockGet).toHaveBeenCalledWith('/health/doctor-schedules/calendar', {
params: { start_date: '2026-05-01', end_date: '2026-05-31', doctor_id: 'd-001' },
})
})
})

View File

@@ -0,0 +1,164 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Appointment {
id: string;
patient_id: string;
doctor_id?: string;
appointment_type: string;
appointment_date: string;
start_time: string;
end_time: string;
status: string;
cancel_reason?: string;
notes?: string;
patient_name?: string;
doctor_name?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateAppointmentReq {
patient_id: string;
doctor_id?: string;
appointment_type?: string;
appointment_date: string;
start_time: string;
end_time: string;
notes?: string;
}
export interface UpdateAppointmentStatusReq {
status: string;
cancel_reason?: string;
}
export interface Schedule {
id: string;
doctor_id: string;
schedule_date: string;
period_type: string;
start_time: string;
end_time: string;
max_appointments: number;
current_appointments: number;
status: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateScheduleReq {
doctor_id: string;
schedule_date: string;
period_type?: string;
start_time: string;
end_time: string;
max_appointments: number;
}
export interface UpdateScheduleReq {
start_time?: string;
end_time?: string;
max_appointments?: number;
status?: string;
}
export interface CalendarDay {
date: string;
schedules: Schedule[];
}
// --- API ---
export const appointmentApi = {
list: async (params: {
page?: number;
page_size?: number;
status?: string;
patient_id?: string;
doctor_id?: string;
date?: string;
search?: string;
appointment_type?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Appointment>;
}>('/health/appointments', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: Appointment;
}>(`/health/appointments/${id}`);
return data.data;
},
create: async (req: CreateAppointmentReq) => {
const { data } = await client.post<{
success: boolean;
data: Appointment;
}>('/health/appointments', req);
return data.data;
},
updateStatus: async (
id: string,
req: UpdateAppointmentStatusReq & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: Appointment;
}>(`/health/appointments/${id}/status`, req);
return data.data;
},
// Schedules
listSchedules: async (params: {
page?: number;
page_size?: number;
doctor_id?: string;
date?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Schedule>;
}>('/health/doctor-schedules', { params });
return data.data;
},
createSchedule: async (req: CreateScheduleReq) => {
const { data } = await client.post<{
success: boolean;
data: Schedule;
}>('/health/doctor-schedules', req);
return data.data;
},
updateSchedule: async (
id: string,
req: UpdateScheduleReq & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: Schedule;
}>(`/health/doctor-schedules/${id}`, req);
return data.data;
},
calendar: async (params: {
start_date: string;
end_date: string;
doctor_id?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: CalendarDay[];
}>('/health/doctor-schedules/calendar', { params });
return data.data;
},
};

View File

@@ -0,0 +1,173 @@
/**
* articles API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { articleApi, articleCategoryApi, articleTagApi } from './articles'
beforeEach(() => {
vi.clearAllMocks()
})
describe('articleApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/articles 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await articleApi.list({ page: 1, page_size: 10, status: 'published', category_id: 'cat-001' })
expect(mockGet).toHaveBeenCalledWith('/health/articles', {
params: { page: 1, page_size: 10, status: 'published', category_id: 'cat-001' },
})
})
it('get 应调用 GET /health/articles/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await articleApi.get('art-001')
expect(mockGet).toHaveBeenCalledWith('/health/articles/art-001')
})
it('create 应调用 POST /health/articles', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { title: '健康饮食指南', content: '正文内容', content_type: 'markdown' as const }
await articleApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/articles', req)
})
it('update 应调用 PUT /health/articles/:id 并传递请求体', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { title: '健康饮食指南(修订)', version: 1 }
await articleApi.update('art-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/articles/art-001', req)
})
it('delete 应调用 DELETE /health/articles/:id', async () => {
mockDelete.mockResolvedValue({ data: { success: true, data: null } })
await articleApi.delete('art-001', 1)
expect(mockDelete).toHaveBeenCalledWith('/health/articles/art-001')
})
it('submit 应调用 POST /health/articles/:id/submit 并传递 version', async () => {
mockPost.mockResolvedValue(fakeRes)
await articleApi.submit('art-001', 2)
expect(mockPost).toHaveBeenCalledWith('/health/articles/art-001/submit', { version: 2 })
})
it('approve 应调用 POST /health/articles/:id/approve', async () => {
mockPost.mockResolvedValue(fakeRes)
await articleApi.approve('art-001', 2)
expect(mockPost).toHaveBeenCalledWith('/health/articles/art-001/approve', { version: 2 })
})
it('reject 应调用 POST /health/articles/:id/reject 并传递 review_note', async () => {
mockPost.mockResolvedValue(fakeRes)
await articleApi.reject('art-001', 2, '内容需要修改')
expect(mockPost).toHaveBeenCalledWith('/health/articles/art-001/reject', {
version: 2,
review_note: '内容需要修改',
})
})
it('unpublish 应调用 POST /health/articles/:id/unpublish', async () => {
mockPost.mockResolvedValue(fakeRes)
await articleApi.unpublish('art-001', 3)
expect(mockPost).toHaveBeenCalledWith('/health/articles/art-001/unpublish', { version: 3 })
})
it('view 应调用 POST /health/articles/:id/view', async () => {
mockPost.mockResolvedValue(fakeRes)
await articleApi.view('art-001')
expect(mockPost).toHaveBeenCalledWith('/health/articles/art-001/view')
})
})
describe('articleCategoryApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/article-categories', async () => {
mockGet.mockResolvedValue(fakeRes)
await articleCategoryApi.list()
expect(mockGet).toHaveBeenCalledWith('/health/article-categories')
})
it('create 应调用 POST /health/article-categories', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '营养健康', sort_order: 1 }
await articleCategoryApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/article-categories', req)
})
it('update 应调用 PUT /health/article-categories/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { name: '营养健康(更新)' }
await articleCategoryApi.update('cat-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/article-categories/cat-001', req)
})
it('delete 应调用 DELETE /health/article-categories/:id', async () => {
mockDelete.mockResolvedValue({ data: { success: true, data: null } })
await articleCategoryApi.delete('cat-001')
expect(mockDelete).toHaveBeenCalledWith('/health/article-categories/cat-001')
})
})
describe('articleTagApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/article-tags', async () => {
mockGet.mockResolvedValue(fakeRes)
await articleTagApi.list()
expect(mockGet).toHaveBeenCalledWith('/health/article-tags')
})
it('create 应调用 POST /health/article-tags', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '高血压', color: '#ff0000' }
await articleTagApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/article-tags', req)
})
it('update 应调用 PUT /health/article-tags/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { name: '高血压管理', version: 1 }
await articleTagApi.update('tag-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/article-tags/tag-001', req)
})
it('delete 应调用 DELETE /health/article-tags/:id 并在 body 传递 version', async () => {
mockDelete.mockResolvedValue({ data: { success: true, data: null } })
await articleTagApi.delete('tag-001', 2)
expect(mockDelete).toHaveBeenCalledWith('/health/article-tags/tag-001', { data: { version: 2 } })
})
})

View File

@@ -0,0 +1,283 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Article Types ---
export type ArticleStatus = 'draft' | 'pending_review' | 'published' | 'rejected';
export type ArticleContentType = 'rich_text' | 'markdown';
export interface ArticleListItem {
id: string;
title: string;
summary?: string;
cover_image?: string;
content_type: ArticleContentType;
status: ArticleStatus;
slug?: string;
category_id?: string;
category_name?: string;
tags?: ArticleTagItem[];
author?: string;
reviewed_by?: string;
reviewed_at?: string;
review_note?: string;
view_count: number;
sort_order: number;
is_public: boolean;
published_at?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface Article extends ArticleListItem {
content?: string;
}
export interface CreateArticleReq {
title: string;
summary?: string;
content?: string;
content_type?: ArticleContentType;
cover_image?: string;
slug?: string;
category_id?: string;
tag_ids?: string[];
sort_order?: number;
is_public?: boolean;
}
export interface UpdateArticleReq {
title?: string;
summary?: string;
content?: string;
content_type?: ArticleContentType;
cover_image?: string;
slug?: string;
category_id?: string;
tag_ids?: string[];
sort_order?: number;
is_public?: boolean;
version: number;
}
export interface ArticleListParams {
page?: number;
page_size?: number;
status?: ArticleStatus;
category_id?: string;
tag_id?: string;
keyword?: string;
}
// --- Category Types ---
export interface ArticleCategory {
id: string;
name: string;
slug?: string;
parent_id?: string;
parent_name?: string;
sort_order: number;
description?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateCategoryReq {
name: string;
slug?: string;
parent_id?: string;
sort_order?: number;
description?: string;
}
export interface UpdateCategoryReq {
name?: string;
slug?: string;
parent_id?: string;
sort_order?: number;
description?: string;
}
// --- Tag Types ---
export interface ArticleTagItem {
id: string;
name: string;
slug?: string;
color?: string;
created_at: string;
version?: number;
}
export interface CreateTagReq {
name: string;
slug?: string;
color?: string;
}
// --- Article API ---
export const articleApi = {
list: async (params: ArticleListParams) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<ArticleListItem>;
}>('/health/articles', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: Article;
}>(`/health/articles/${id}`);
return data.data;
},
create: async (req: CreateArticleReq) => {
const { data } = await client.post<{
success: boolean;
data: Article;
}>('/health/articles', req);
return data.data;
},
update: async (id: string, req: UpdateArticleReq) => {
const { data } = await client.put<{
success: boolean;
data: Article;
}>(`/health/articles/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/articles/${id}`, { data: { version } });
return data.data;
},
submit: async (id: string, version: number) => {
const { data } = await client.post<{
success: boolean;
data: Article;
}>(`/health/articles/${id}/submit`, { version });
return data.data;
},
approve: async (id: string, version: number) => {
const { data } = await client.post<{
success: boolean;
data: Article;
}>(`/health/articles/${id}/approve`, { version });
return data.data;
},
reject: async (id: string, version: number, review_note: string) => {
const { data } = await client.post<{
success: boolean;
data: Article;
}>(`/health/articles/${id}/reject`, { version, review_note });
return data.data;
},
unpublish: async (id: string, version: number) => {
const { data } = await client.post<{
success: boolean;
data: Article;
}>(`/health/articles/${id}/unpublish`, { version });
return data.data;
},
view: async (id: string) => {
const { data } = await client.post<{
success: boolean;
data: Article;
}>(`/health/articles/${id}/view`);
return data.data;
},
listRevisions: async (id: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Record<string, unknown>>;
}>(`/health/articles/${id}/revisions`, { params });
return data.data;
},
};
// --- Category API ---
export const articleCategoryApi = {
list: async () => {
const { data } = await client.get<{
success: boolean;
data: ArticleCategory[];
}>('/health/article-categories');
return data.data;
},
create: async (req: CreateCategoryReq) => {
const { data } = await client.post<{
success: boolean;
data: ArticleCategory;
}>('/health/article-categories', req);
return data.data;
},
update: async (id: string, req: UpdateCategoryReq) => {
const { data } = await client.put<{
success: boolean;
data: ArticleCategory;
}>(`/health/article-categories/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/article-categories/${id}`, { data: { version } });
return data.data;
},
};
// --- Tag API ---
export const articleTagApi = {
list: async () => {
const { data } = await client.get<{
success: boolean;
data: ArticleTagItem[];
}>('/health/article-tags');
return data.data;
},
create: async (req: CreateTagReq) => {
const { data } = await client.post<{
success: boolean;
data: ArticleTagItem;
}>('/health/article-tags', req);
return data.data;
},
update: async (id: string, req: { name: string; version: number }) => {
const { data } = await client.put<{
success: boolean;
data: ArticleTagItem;
}>(`/health/article-tags/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/article-tags/${id}`, { data: { version } });
return data.data;
},
};

View File

@@ -0,0 +1,116 @@
import client from '../client';
// ---------------------------------------------------------------------------
// 轮播图类型
// ---------------------------------------------------------------------------
export interface BannerItem {
id: string;
tenant_id: string;
media_item_id: string;
title?: string;
subtitle?: string;
link_type?: string;
link_target?: string;
sort_order: number;
status: string;
start_time?: string;
end_time?: string;
image_url?: string;
thumbnail_url?: string;
media_deleted: boolean;
created_at: string;
updated_at: string;
created_by?: string;
updated_by?: string;
version: number;
}
export interface CreateBannerReq {
media_item_id: string;
title?: string;
subtitle?: string;
link_type?: string;
link_target?: string;
sort_order?: number;
status?: string;
start_time?: string;
end_time?: string;
}
export interface UpdateBannerReq {
media_item_id?: string;
title?: string;
subtitle?: string;
link_type?: string;
link_target?: string;
sort_order?: number;
status?: string;
start_time?: string;
end_time?: string;
version: number;
}
export interface SortBannerReq {
items: Array<{ id: string; sort_order: number }>;
}
// ---------------------------------------------------------------------------
// 轮播图 API
// ---------------------------------------------------------------------------
export const bannerApi = {
/** 获取轮播图列表(可按状态筛选) */
list: async (status?: string) => {
const { data } = await client.get<{
success: boolean;
data: BannerItem[];
}>('/health/banners', { params: status ? { status } : undefined });
return data.data;
},
/** 获取单个轮播图 */
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: BannerItem;
}>(`/health/banners/${id}`);
return data.data;
},
/** 创建轮播图 */
create: async (req: CreateBannerReq) => {
const { data } = await client.post<{
success: boolean;
data: BannerItem;
}>('/health/banners', req);
return data.data;
},
/** 更新轮播图 */
update: async (id: string, req: UpdateBannerReq) => {
const { data } = await client.put<{
success: boolean;
data: BannerItem;
}>(`/health/banners/${id}`, req);
return data.data;
},
/** 删除轮播图 */
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/banners/${id}`, { data: { version } });
return data.data;
},
/** 轮播图排序 */
sort: async (req: SortBannerReq) => {
const { data } = await client.put<{
success: boolean;
data: null;
}>('/health/banners/sort', req);
return data.data;
},
};

View File

@@ -0,0 +1,170 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface BleGateway {
id: string;
tenant_id: string;
gateway_id: string;
name: string;
status: string;
firmware_version?: string;
ip_address?: string;
last_heartbeat_at?: string;
metadata?: Record<string, unknown>;
created_at: string;
updated_at: string;
version: number;
api_key?: string;
patient_count?: number;
}
export interface GatewayBinding {
id: string;
tenant_id: string;
gateway_id: string;
patient_id: string;
peripheral_mac?: string;
device_type?: string;
status: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateBleGatewayReq {
gateway_id: string;
name: string;
firmware_version?: string;
metadata?: Record<string, unknown>;
}
export interface UpdateBleGatewayReq {
name?: string;
status?: string;
firmware_version?: string;
metadata?: Record<string, unknown>;
}
export interface ListBleGatewaysParams {
page?: number;
page_size?: number;
status?: string;
}
export interface CreateBindingReq {
patient_id: string;
peripheral_mac?: string;
device_type?: string;
}
export interface BatchBindReq {
bindings: CreateBindingReq[];
}
// --- Constants ---
export const GATEWAY_STATUS_OPTIONS = [
{ label: '在线', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '未激活', value: 'inactive' },
{ label: '已禁用', value: 'disabled' },
];
export const GATEWAY_STATUS_COLOR: Record<string, string> = {
online: 'green',
offline: 'red',
inactive: 'default',
disabled: 'error',
};
export const GATEWAY_STATUS_LABEL: Record<string, string> = Object.fromEntries(
GATEWAY_STATUS_OPTIONS.map((o) => [o.value, o.label]),
);
export const BINDING_STATUS_COLOR: Record<string, string> = {
active: 'green',
inactive: 'default',
unbound: 'error',
};
// --- API ---
export const bleGatewayApi = {
// --- Gateways ---
list: async (params?: ListBleGatewaysParams) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<BleGateway>;
}>('/health/ble-gateways', { params });
return data.data;
},
get: async (gatewayId: string) => {
const { data } = await client.get<{
success: boolean;
data: BleGateway;
}>(`/health/ble-gateways/${gatewayId}`);
return data.data;
},
create: async (req: CreateBleGatewayReq) => {
const { data } = await client.post<{
success: boolean;
data: BleGateway;
}>('/health/ble-gateways', req);
return data.data;
},
update: async (gatewayId: string, req: UpdateBleGatewayReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: BleGateway;
}>(`/health/ble-gateways/${gatewayId}`, req);
return data.data;
},
delete: async (gatewayId: string, version: number) => {
await client.delete(`/health/ble-gateways/${gatewayId}`, { data: { version } });
},
regenerateKey: async (gatewayId: string) => {
const { data } = await client.post<{
success: boolean;
data: BleGateway;
}>(`/health/ble-gateways/${gatewayId}/regenerate-key`);
return data.data;
},
// --- Bindings ---
listBindings: async (gatewayId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<GatewayBinding>;
}>(`/health/ble-gateways/${gatewayId}/bindings`, { params });
return data.data;
},
bindPatient: async (gatewayId: string, req: CreateBindingReq) => {
const { data } = await client.post<{
success: boolean;
data: GatewayBinding;
}>(`/health/ble-gateways/${gatewayId}/bindings`, req);
return data.data;
},
batchBind: async (gatewayId: string, req: BatchBindReq) => {
const { data } = await client.post<{
success: boolean;
data: GatewayBinding[];
}>(`/health/ble-gateways/${gatewayId}/bindings/batch`, req);
return data.data;
},
unbindPatient: async (gatewayId: string, bindingId: string, version: number) => {
await client.delete(`/health/ble-gateways/${gatewayId}/bindings/${bindingId}`, { data: { version } });
},
};

View File

@@ -0,0 +1,245 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface CarePlan {
id: string;
patient_id: string;
plan_type: string;
status: string;
title: string;
goals?: Record<string, unknown>;
start_date?: string;
end_date?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CarePlanItem {
id: string;
plan_id: string;
item_type: string;
title: string;
description?: string;
status: string;
schedule?: string;
sort_order?: number;
created_at: string;
updated_at: string;
version: number;
}
export interface CarePlanOutcome {
id: string;
plan_id: string;
item_id?: string;
metric: string;
baseline_value: string;
target_value: string;
current_value?: string;
measured_at?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateCarePlanReq {
patient_id: string;
plan_type: string;
title: string;
goals?: Record<string, unknown>;
start_date?: string;
end_date?: string;
notes?: string;
}
export interface UpdateCarePlanReq {
plan_type?: string;
title?: string;
status?: string;
goals?: Record<string, unknown>;
start_date?: string;
end_date?: string;
notes?: string;
}
export interface CreateCarePlanItemReq {
item_type: string;
title: string;
description?: string;
schedule?: string;
sort_order?: number;
}
export interface UpdateCarePlanItemReq {
item_type?: string;
title?: string;
description?: string;
status?: string;
schedule?: string;
sort_order?: number;
}
export interface CreateCarePlanOutcomeReq {
item_id?: string;
metric: string;
baseline_value: string;
target_value: string;
current_value?: string;
measured_at?: string;
notes?: string;
}
export interface UpdateCarePlanOutcomeReq {
item_id?: string;
metric?: string;
baseline_value?: string;
target_value?: string;
current_value?: string;
measured_at?: string;
notes?: string;
}
export interface ListCarePlansParams {
page?: number;
page_size?: number;
patient_id?: string;
plan_type?: string;
status?: string;
}
// --- Constants ---
export const PLAN_TYPE_OPTIONS = [
{ label: '血液透析', value: 'hemodialysis' },
{ label: '腹膜透析', value: 'peritoneal' },
{ label: '慢性病管理', value: 'chronic_disease' },
{ label: '康复计划', value: 'rehabilitation' },
];
export const PLAN_STATUS_OPTIONS = [
{ label: '草稿', value: 'draft' },
{ label: '进行中', value: 'active' },
{ label: '已完成', value: 'completed' },
{ label: '已取消', value: 'cancelled' },
];
export const ITEM_TYPE_OPTIONS = [
{ label: '药物干预', value: 'medication' },
{ label: '饮食管理', value: 'diet' },
{ label: '运动计划', value: 'exercise' },
{ label: '监测项目', value: 'monitoring' },
{ label: '教育指导', value: 'education' },
{ label: '其他', value: 'other' },
];
export const PLAN_STATUS_COLOR: Record<string, string> = {
draft: 'default',
active: 'processing',
completed: 'success',
cancelled: 'error',
};
// --- API ---
export const carePlanApi = {
list: async (params: ListCarePlansParams) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<CarePlan>;
}>('/health/care-plans', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: CarePlan;
}>(`/health/care-plans/${id}`);
return data.data;
},
create: async (req: CreateCarePlanReq) => {
const { data } = await client.post<{
success: boolean;
data: CarePlan;
}>('/health/care-plans', req);
return data.data;
},
update: async (id: string, req: UpdateCarePlanReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: CarePlan;
}>(`/health/care-plans/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
await client.delete(`/health/care-plans/${id}`, { data: { version } });
},
// --- Items ---
listItems: async (planId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<CarePlanItem>;
}>(`/health/care-plans/${planId}/items`, { params });
return data.data;
},
createItem: async (planId: string, req: CreateCarePlanItemReq) => {
const { data } = await client.post<{
success: boolean;
data: CarePlanItem;
}>(`/health/care-plans/${planId}/items`, req);
return data.data;
},
updateItem: async (planId: string, itemId: string, req: UpdateCarePlanItemReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: CarePlanItem;
}>(`/health/care-plans/${planId}/items/${itemId}`, req);
return data.data;
},
deleteItem: async (planId: string, itemId: string, version: number) => {
await client.delete(`/health/care-plans/${planId}/items/${itemId}`, { data: { version } });
},
// --- Outcomes ---
listOutcomes: async (planId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<CarePlanOutcome>;
}>(`/health/care-plans/${planId}/outcomes`, { params });
return data.data;
},
createOutcome: async (planId: string, req: CreateCarePlanOutcomeReq) => {
const { data } = await client.post<{
success: boolean;
data: CarePlanOutcome;
}>(`/health/care-plans/${planId}/outcomes`, req);
return data.data;
},
updateOutcome: async (planId: string, outcomeId: string, req: UpdateCarePlanOutcomeReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: CarePlanOutcome;
}>(`/health/care-plans/${planId}/outcomes/${outcomeId}`, req);
return data.data;
},
deleteOutcome: async (planId: string, outcomeId: string, version: number) => {
await client.delete(`/health/care-plans/${planId}/outcomes/${outcomeId}`, { data: { version } });
},
};

View File

@@ -0,0 +1,92 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Consent {
id: string;
patient_id: string;
consent_type: string;
consent_scope: string;
status: string;
granted_at?: string;
revoked_at?: string;
expiry_date?: string;
consent_method?: string;
witness_name?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateConsentReq {
patient_id: string;
consent_type: string;
consent_scope: string;
expiry_date?: string;
consent_method?: string;
witness_name?: string;
notes?: string;
}
export interface RevokeConsentReq {
notes?: string;
version: number;
}
// --- Constants ---
export const CONSENT_TYPE_OPTIONS = [
{ label: '治疗同意', value: 'treatment' },
{ label: '数据共享', value: 'data_sharing' },
{ label: '隐私政策', value: 'privacy' },
{ label: '研究参与', value: 'research' },
];
export const CONSENT_SCOPE_OPTIONS = [
{ label: '全部', value: 'all' },
{ label: '健康数据', value: 'health_data' },
{ label: '基本信息', value: 'basic_info' },
{ label: '体检报告', value: 'examination' },
];
export const CONSENT_STATUS_COLOR: Record<string, string> = {
active: 'green',
revoked: 'red',
expired: 'default',
};
export const CONSENT_STATUS_LABEL: Record<string, string> = {
active: '生效中',
revoked: '已撤销',
expired: '已过期',
};
// --- API ---
export const consentApi = {
list: async (patientId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Consent>;
}>(`/health/patients/${patientId}/consents`, { params });
return data.data;
},
grant: async (req: CreateConsentReq) => {
const { data } = await client.post<{
success: boolean;
data: Consent;
}>('/health/consents', req);
return data.data;
},
revoke: async (consentId: string, req: RevokeConsentReq) => {
const { data } = await client.put<{
success: boolean;
data: Consent;
}>(`/health/consents/${consentId}/revoke`, req);
return data.data;
},
};

View File

@@ -0,0 +1,98 @@
/**
* consultations API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { consultationApi } from './consultations'
beforeEach(() => {
vi.clearAllMocks()
})
describe('consultationApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listSessions 应调用 GET /health/consultation-sessions 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await consultationApi.listSessions({ page: 1, page_size: 20, status: 'active', patient_id: 'p-001' })
expect(mockGet).toHaveBeenCalledWith('/health/consultation-sessions', {
params: { page: 1, page_size: 20, status: 'active', patient_id: 'p-001' },
})
})
it('createSession 应调用 POST /health/consultation-sessions', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { patient_id: 'p-001', doctor_id: 'd-001', consultation_type: 'online' }
await consultationApi.createSession(req)
expect(mockPost).toHaveBeenCalledWith('/health/consultation-sessions', req)
})
it('getSession 应调用 GET /health/consultation-sessions/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await consultationApi.getSession('sess-001')
expect(mockGet).toHaveBeenCalledWith('/health/consultation-sessions/sess-001')
})
it('closeSession 应调用 PUT /health/consultation-sessions/:id/close', async () => {
mockPut.mockResolvedValue(fakeRes)
await consultationApi.closeSession('sess-001', { version: 1 })
expect(mockPut).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/close', { version: 1 })
})
it('listMessages 应调用 GET /health/consultation-sessions/:id/messages 并传递分页参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await consultationApi.listMessages('sess-001', { page: 2, page_size: 50, after_id: 'msg-100' })
expect(mockGet).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/messages', {
params: { page: 2, page_size: 50, after_id: 'msg-100' },
})
})
it('createMessage 应调用 POST /health/consultation-messages', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { session_id: 'sess-001', content_type: 'text', content: '你好' }
await consultationApi.createMessage(req)
expect(mockPost).toHaveBeenCalledWith('/health/consultation-messages', req)
})
it('createFollowUpFromSession 应调用 POST /health/consultation-sessions/:id/follow-up', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { follow_up_type: 'phone', planned_date: '2026-06-01' }
await consultationApi.createFollowUpFromSession('sess-001', req)
expect(mockPost).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/follow-up', req)
})
it('triggerAiAnalysisFromSession 应调用 POST /health/consultation-sessions/:id/ai-analysis', async () => {
mockPost.mockResolvedValue(fakeRes)
await consultationApi.triggerAiAnalysisFromSession('sess-001')
expect(mockPost).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/ai-analysis', {})
})
it('triggerAiAnalysisFromSession 传入 analysis_type 时应携带参数', async () => {
mockPost.mockResolvedValue(fakeRes)
await consultationApi.triggerAiAnalysisFromSession('sess-001', { analysis_type: 'trend' })
expect(mockPost).toHaveBeenCalledWith('/health/consultation-sessions/sess-001/ai-analysis', { analysis_type: 'trend' })
})
})

View File

@@ -0,0 +1,185 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Session {
id: string;
patient_id: string;
doctor_id?: string;
patient_name?: string;
doctor_name?: string;
consultation_type: string;
status: string;
last_message_at?: string;
unread_count_patient: number;
unread_count_doctor: number;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateSessionReq {
patient_id: string;
doctor_id?: string;
consultation_type?: string;
}
export interface Message {
id: string;
session_id: string;
sender_id: string;
sender_role: string;
content_type: string;
content: string;
is_read: boolean;
created_at: string;
}
export interface CreateMessageReq {
session_id: string;
content_type?: string;
content: string;
}
// --- 咨询联动请求类型 ---
export interface CreateFollowUpFromConsultationReq {
follow_up_type: string;
planned_date: string;
assigned_to?: string;
content_template?: string;
}
export interface FollowUpFromConsultationResp {
task_id: string;
session_id: string;
patient_id: string;
}
export interface TriggerAiAnalysisReq {
analysis_type?: string;
}
export interface AiAnalysisTriggeredResp {
session_id: string;
patient_id: string;
analysis_type: string;
}
// --- API ---
export const consultationApi = {
listSessions: async (params: {
page?: number;
page_size?: number;
status?: string;
patient_id?: string;
doctor_id?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Session>;
}>('/health/consultation-sessions', { params });
return data.data;
},
createSession: async (req: CreateSessionReq) => {
const { data } = await client.post<{
success: boolean;
data: Session;
}>('/health/consultation-sessions', req);
return data.data;
},
getSession: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: Session;
}>(`/health/consultation-sessions/${id}`);
return data.data;
},
closeSession: async (
id: string,
req: { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: Session;
}>(`/health/consultation-sessions/${id}/close`, req);
return data.data;
},
listMessages: async (
sessionId: string,
params: { page?: number; page_size?: number; after_id?: string },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Message>;
}>(`/health/consultation-sessions/${sessionId}/messages`, { params });
return data.data;
},
createMessage: async (req: CreateMessageReq) => {
const { data } = await client.post<{
success: boolean;
data: Message;
}>('/health/consultation-messages', req);
return data.data;
},
pollMessages: async (
sessionId: string,
afterId?: string,
) => {
const { data } = await client.get<{
success: boolean;
data: Message[];
}>(`/health/consultation-sessions/${sessionId}/messages/poll`, {
params: { after_id: afterId, timeout: 25 },
timeout: 30000,
});
return data.data;
},
markSessionRead: async (id: string) => {
await client.put(`/health/consultation-sessions/${id}/read`);
},
exportSessions: async (params?: {
status?: string;
patient_id?: string;
doctor_id?: string;
page?: number;
page_size?: number;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Session>;
}>('/health/consultation-sessions/export', { params });
return data.data;
},
/** 从咨询会话创建随访任务 */
createFollowUpFromSession: async (
sessionId: string,
req: CreateFollowUpFromConsultationReq,
) => {
const { data } = await client.post<{
success: boolean;
data: FollowUpFromConsultationResp;
}>(`/health/consultation-sessions/${sessionId}/follow-up`, req);
return data.data;
},
/** 从咨询会话触发 AI 分析 */
triggerAiAnalysisFromSession: async (
sessionId: string,
req?: TriggerAiAnalysisReq,
) => {
const { data } = await client.post<{
success: boolean;
data: AiAnalysisTriggeredResp;
}>(`/health/consultation-sessions/${sessionId}/ai-analysis`, req ?? {});
return data.data;
},
};

View File

@@ -0,0 +1,110 @@
import client from '../client';
// --- Types ---
export interface CriticalValueThreshold {
id: string;
tenant_id: string;
indicator: string;
direction: string;
threshold_value: number;
level: string;
department?: string;
age_min?: number;
age_max?: number;
is_active: boolean;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateThresholdReq {
indicator: string;
direction: string;
threshold_value: number;
level?: string;
department?: string;
age_min?: number;
age_max?: number;
}
export interface UpdateThresholdReq {
threshold_value: number;
level?: string;
department?: string;
age_min?: number;
age_max?: number;
version: number;
}
// --- Constants ---
export const INDICATOR_OPTIONS = [
{ label: '收缩压', value: 'systolic_bp' },
{ label: '舒张压', value: 'diastolic_bp' },
{ label: '心率', value: 'heart_rate' },
{ label: '血糖', value: 'blood_sugar' },
{ label: '空腹血糖', value: 'blood_sugar_fasting' },
{ label: '餐后血糖', value: 'blood_sugar_postprandial' },
{ label: '血氧', value: 'blood_oxygen' },
{ label: '体温', value: 'temperature' },
];
export const DIRECTION_OPTIONS = [
{ label: '偏高', value: 'high' },
{ label: '偏低', value: 'low' },
];
export const LEVEL_OPTIONS = [
{ label: '危急', value: 'critical' },
{ label: '警告', value: 'warning' },
];
export const LEVEL_COLOR: Record<string, string> = {
critical: 'red',
warning: 'orange',
};
export const INDICATOR_LABEL: Record<string, string> = Object.fromEntries(
INDICATOR_OPTIONS.map((o) => [o.value, o.label]),
);
export const DIRECTION_LABEL: Record<string, string> = Object.fromEntries(
DIRECTION_OPTIONS.map((o) => [o.value, o.label]),
);
export const LEVEL_LABEL: Record<string, string> = Object.fromEntries(
LEVEL_OPTIONS.map((o) => [o.value, o.label]),
);
// --- API ---
export const criticalValueThresholdApi = {
list: async () => {
const { data } = await client.get<{
success: boolean;
data: CriticalValueThreshold[];
}>('/health/critical-value-thresholds');
return data.data;
},
create: async (req: CreateThresholdReq) => {
const { data } = await client.post<{
success: boolean;
data: CriticalValueThreshold;
}>('/health/critical-value-thresholds', req);
return data.data;
},
update: async (id: string, req: UpdateThresholdReq) => {
const { data } = await client.put<{
success: boolean;
data: CriticalValueThreshold;
}>(`/health/critical-value-thresholds/${id}`, req);
return data.data;
},
delete: async (id: string) => {
await client.delete(`/health/critical-value-thresholds/${id}`);
},
};

View File

@@ -0,0 +1,105 @@
/**
* dashboard + actionInbox API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { dashboardApi } from './dashboard'
import { actionInboxApi } from './actionInbox'
beforeEach(() => {
vi.clearAllMocks()
})
describe('dashboardApi', () => {
const fakeRes = { data: { data: {} } }
it('getSystemHealth 应调用 GET /health/admin/system-health', async () => {
mockGet.mockResolvedValue(fakeRes)
await dashboardApi.getSystemHealth()
expect(mockGet).toHaveBeenCalledWith('/health/admin/system-health')
})
it('getUserActivity 应调用 GET /health/admin/user-activity', async () => {
mockGet.mockResolvedValue(fakeRes)
await dashboardApi.getUserActivity()
expect(mockGet).toHaveBeenCalledWith('/health/admin/user-activity')
})
it('getModuleStatus 应调用 GET /health/admin/modules', async () => {
mockGet.mockResolvedValue(fakeRes)
await dashboardApi.getModuleStatus()
expect(mockGet).toHaveBeenCalledWith('/health/admin/modules')
})
it('getPointsRecentActivity 应调用 GET /health/points/recent-activity', async () => {
mockGet.mockResolvedValue(fakeRes)
await dashboardApi.getPointsRecentActivity()
expect(mockGet).toHaveBeenCalledWith('/health/points/recent-activity')
})
it('getArticleStats 应调用 GET /health/articles/stats', async () => {
mockGet.mockResolvedValue(fakeRes)
await dashboardApi.getArticleStats()
expect(mockGet).toHaveBeenCalledWith('/health/articles/stats')
})
})
describe('actionInboxApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/action-inbox 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await actionInboxApi.list({ status: 'pending', type: 'alert', page: 1, page_size: 20 })
expect(mockGet).toHaveBeenCalledWith('/health/action-inbox', {
params: { status: 'pending', type: 'alert', page: 1, page_size: 20 },
})
})
it('getThread 应调用 GET /health/action-inbox/:sourceRef/thread', async () => {
mockGet.mockResolvedValue(fakeRes)
await actionInboxApi.getThread('ref-001')
expect(mockGet).toHaveBeenCalledWith('/health/action-inbox/ref-001/thread')
})
it('getThread 应对特殊字符 URL 编码', async () => {
mockGet.mockResolvedValue(fakeRes)
await actionInboxApi.getThread('ref/with:special')
expect(mockGet).toHaveBeenCalledWith('/health/action-inbox/ref%2Fwith%3Aspecial/thread')
})
it('stats 应调用 GET /health/action-inbox/stats', async () => {
mockGet.mockResolvedValue(fakeRes)
await actionInboxApi.stats()
expect(mockGet).toHaveBeenCalledWith('/health/action-inbox/stats')
})
it('team 应调用 GET /health/action-inbox/team', async () => {
mockGet.mockResolvedValue(fakeRes)
await actionInboxApi.team()
expect(mockGet).toHaveBeenCalledWith('/health/action-inbox/team')
})
})

View File

@@ -0,0 +1,69 @@
import client from '../client';
export interface ServiceHealthStatus {
name: string;
status: string;
message: string;
response_ms: number | null;
}
export interface SystemHealthResp {
services: ServiceHealthStatus[];
checked_at: string;
}
export interface RoleCount {
role: string;
count: number;
}
export interface UserActivityResp {
daily_active: number;
weekly_active: number;
monthly_active: number;
total_registered: number;
by_role: RoleCount[];
}
export interface ModuleStatusResp {
name: string;
display_name: string;
description: string;
active: boolean;
entity_count: number | null;
route_count: number | null;
}
export interface PointsActivityItem {
id: string;
user_name: string;
detail: string;
amount: string;
type: string;
created_at: string;
}
export interface ArticleStatsResp {
published: number;
draft: number;
pending_review: number;
rejected: number;
total_views: number;
}
export const dashboardApi = {
getSystemHealth: () =>
client.get('/health/admin/system-health').then((r) => r.data.data as SystemHealthResp),
getUserActivity: () =>
client.get('/health/admin/user-activity').then((r) => r.data.data as UserActivityResp),
getModuleStatus: () =>
client.get('/health/admin/modules').then((r) => r.data.data as ModuleStatusResp[]),
getPointsRecentActivity: () =>
client.get('/health/points/recent-activity').then((r) => r.data.data as PointsActivityItem[]),
getArticleStats: () =>
client.get('/health/articles/stats').then((r) => r.data.data as ArticleStatsResp),
};

View File

@@ -0,0 +1,82 @@
/**
* deviceReadings + devices API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { deviceReadingApi } from './deviceReadings'
import { deviceApi } from './devices'
beforeEach(() => {
vi.clearAllMocks()
})
describe('deviceReadingApi', () => {
const fakeRes = { data: { data: {} } }
it('batchCreate 应调用 POST /health/patients/:id/device-readings/batch', async () => {
mockPost.mockResolvedValue(fakeRes)
const data = {
device_id: 'dev-001',
readings: [
{ device_type: 'blood_pressure', values: { systolic: 130, diastolic: 85 }, measured_at: '2026-05-03T08:00:00Z' },
],
}
await deviceReadingApi.batchCreate('p-001', data)
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/device-readings/batch', data)
})
it('query 应调用 GET /health/patients/:id/device-readings 并剥离 patient_id', async () => {
mockGet.mockResolvedValue(fakeRes)
await deviceReadingApi.query({ patient_id: 'p-001', device_type: 'blood_pressure', hours: 24 })
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/device-readings', {
params: { device_type: 'blood_pressure', hours: 24 },
})
})
it('queryHourly 应调用 GET /health/patients/:id/device-readings/hourly', async () => {
mockGet.mockResolvedValue(fakeRes)
await deviceReadingApi.queryHourly({ patient_id: 'p-001', device_type: 'blood_pressure', days: 7 })
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/device-readings/hourly', {
params: { device_type: 'blood_pressure', days: 7 },
})
})
})
describe('deviceApi', () => {
const fakeRes = { data: { data: {} } }
it('listDevices 应调用 GET /health/devices 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await deviceApi.listDevices({ patient_id: 'p-001', device_type: 'blood_pressure', page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith('/health/devices', {
params: { patient_id: 'p-001', device_type: 'blood_pressure', page: 1, page_size: 10 },
})
})
it('unbindDevice 应调用 DELETE /health/devices/:id 并在 body 传递 version', async () => {
mockDelete.mockResolvedValue(fakeRes)
await deviceApi.unbindDevice('dev-001', 2)
expect(mockDelete).toHaveBeenCalledWith('/health/devices/dev-001', {
data: { version: 2 },
})
})
})

View File

@@ -0,0 +1,72 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface DeviceReading {
id: string;
device_id?: string;
device_type: string;
device_model?: string;
raw_value: Record<string, unknown>;
measured_at: string;
created_at: string;
}
export interface HourlyReading {
id: string;
device_type: string;
hour_start: string;
min_val?: number;
max_val?: number;
avg_val: number;
sample_count: number;
}
export interface DailyReading {
id: string;
device_type: string;
date_bucket: string;
min_val?: number;
max_val?: number;
avg_val: number;
sample_count: number;
percentile_95?: number;
}
export interface BatchReadingRequest {
device_id: string;
device_model?: string;
readings: {
device_type: string;
values: Record<string, unknown>;
measured_at: string;
}[];
}
export interface BatchResult {
accepted: number;
duplicates: number;
earliest?: string;
latest?: string;
}
// --- API ---
export const deviceReadingApi = {
batchCreate: (patientId: string, data: BatchReadingRequest) =>
client.post(`/health/patients/${patientId}/device-readings/batch`, data).then((r) => r.data.data as BatchResult),
query: (params: { patient_id: string; device_type?: string; hours?: number; page?: number; page_size?: number }) => {
const { patient_id, ...query } = params;
return client.get(`/health/patients/${patient_id}/device-readings`, { params: query }).then((r) => r.data.data as PaginatedResponse<DeviceReading>);
},
queryHourly: (params: { patient_id: string; device_type: string; days?: number; page?: number; page_size?: number }) => {
const { patient_id, ...query } = params;
return client.get(`/health/patients/${patient_id}/device-readings/hourly`, { params: query }).then((r) => r.data.data as PaginatedResponse<HourlyReading>);
},
queryDaily: (params: { patient_id: string; device_type?: string; from_date?: string; to_date?: string; page?: number; page_size?: number }) => {
const { patient_id, ...query } = params;
return client.get(`/health/vital-signs/daily`, { params: { ...query, patient_id } }).then((r) => r.data.data as PaginatedResponse<DailyReading>);
},
};

View File

@@ -0,0 +1,37 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface DeviceItem {
id: string;
patient_id: string;
device_id: string;
device_model: string;
device_type: string;
status?: string;
firmware_version?: string;
manufacturer?: string;
connection_type?: string;
metadata?: Record<string, unknown>;
bound_at: string;
last_sync_at: string;
version: number;
}
// --- API ---
export const deviceApi = {
listDevices: (params?: {
patient_id?: string;
device_type?: string;
page?: number;
page_size?: number;
}) =>
client
.get('/health/devices', { params })
.then((r) => r.data.data as PaginatedResponse<DeviceItem>),
unbindDevice: (id: string, version: number) =>
client
.delete(`/health/devices/${id}`, { data: { version } })
.then((r) => r.data.data as DeviceItem),
};

View File

@@ -0,0 +1,108 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Diagnosis {
id: string;
patient_id: string;
health_record_id?: string;
icd_code: string;
diagnosis_name: string;
diagnosis_type: string;
diagnosed_date: string;
status: string;
diagnosed_by?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateDiagnosisReq {
icd_code: string;
diagnosis_name: string;
diagnosis_type?: string;
diagnosed_date: string;
status?: string;
health_record_id?: string;
diagnosed_by?: string;
notes?: string;
}
export interface UpdateDiagnosisReq {
icd_code?: string;
diagnosis_name?: string;
diagnosis_type?: string;
diagnosed_date?: string;
status?: string;
health_record_id?: string;
diagnosed_by?: string;
notes?: string;
}
// --- Constants ---
export const DIAGNOSIS_TYPE_OPTIONS = [
{ label: '主要诊断', value: 'primary' },
{ label: '次要诊断', value: 'secondary' },
{ label: '合并症', value: 'comorbid' },
];
export const DIAGNOSIS_STATUS_OPTIONS = [
{ label: '活跃', value: 'active' },
{ label: '已缓解', value: 'resolved' },
{ label: '慢性', value: 'chronic' },
];
export const DIAGNOSIS_TYPE_COLOR: Record<string, string> = {
primary: 'red',
secondary: 'blue',
comorbid: 'orange',
};
export const DIAGNOSIS_STATUS_COLOR: Record<string, string> = {
active: 'green',
resolved: 'default',
chronic: 'orange',
};
export const DIAGNOSIS_TYPE_LABEL: Record<string, string> = Object.fromEntries(
DIAGNOSIS_TYPE_OPTIONS.map((o) => [o.value, o.label]),
);
export const DIAGNOSIS_STATUS_LABEL: Record<string, string> = Object.fromEntries(
DIAGNOSIS_STATUS_OPTIONS.map((o) => [o.value, o.label]),
);
// --- API ---
export const diagnosisApi = {
list: async (patientId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Diagnosis>;
}>(`/health/patients/${patientId}/diagnoses`, { params });
return data.data;
},
create: async (patientId: string, req: CreateDiagnosisReq) => {
const { data } = await client.post<{
success: boolean;
data: Diagnosis;
}>(`/health/patients/${patientId}/diagnoses`, req);
return data.data;
},
update: async (diagnosisId: string, req: UpdateDiagnosisReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: Diagnosis;
}>(`/health/diagnoses/${diagnosisId}`, req);
return data.data;
},
delete: async (diagnosisId: string, version: number) => {
await client.delete(`/health/diagnoses/${diagnosisId}`, { data: { version } });
},
};

View File

@@ -0,0 +1,116 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface DialysisRecord {
id: string;
patient_id: string;
dialysis_date: string;
start_time?: string;
end_time?: string;
dry_weight?: number;
pre_weight?: number;
post_weight?: number;
pre_bp_systolic?: number;
pre_bp_diastolic?: number;
post_bp_systolic?: number;
post_bp_diastolic?: number;
pre_heart_rate?: number;
post_heart_rate?: number;
ultrafiltration_volume?: number;
dialysis_duration?: number;
blood_flow_rate?: number;
dialysis_type: string;
symptoms?: Record<string, unknown>;
complication_notes?: string;
status: string;
reviewed_by?: string;
reviewed_at?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateDialysisRecordReq {
patient_id: string;
dialysis_date: string;
start_time?: string;
end_time?: string;
dry_weight?: number;
pre_weight?: number;
post_weight?: number;
pre_bp_systolic?: number;
pre_bp_diastolic?: number;
post_bp_systolic?: number;
post_bp_diastolic?: number;
pre_heart_rate?: number;
post_heart_rate?: number;
ultrafiltration_volume?: number;
dialysis_duration?: number;
blood_flow_rate?: number;
dialysis_type?: string;
complication_notes?: string;
}
// --- API ---
export const dialysisApi = {
listRecords: async (
patientId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<DialysisRecord>;
}>(`/health/patients/${patientId}/dialysis-records`, { params });
return data.data;
},
getRecord: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: DialysisRecord;
}>(`/health/dialysis-records/${id}`);
return data.data;
},
createRecord: async (req: CreateDialysisRecordReq) => {
const { data } = await client.post<{
success: boolean;
data: DialysisRecord;
}>('/health/dialysis-records', req);
return data.data;
},
updateRecord: async (
id: string,
req: Partial<CreateDialysisRecordReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: DialysisRecord;
}>(`/health/dialysis-records/${id}`, req);
return data.data;
},
deleteRecord: async (id: string, version: number) => {
await client.delete(`/health/dialysis-records/${id}`, { data: { version } });
},
reviewRecord: async (id: string, req: { version: number; doctor_notes?: string }) => {
const { data } = await client.put<{
success: boolean;
data: Record<string, unknown>;
}>(`/health/dialysis-records/${id}/review`, req);
return data.data;
},
completeRecord: async (id: string, version: number) => {
const { data } = await client.put<{
success: boolean;
data: DialysisRecord;
}>(`/health/dialysis-records/${id}/complete`, { version });
return data.data;
},
};

View File

@@ -0,0 +1,67 @@
/**
* doctors API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { doctorApi } from './doctors'
beforeEach(() => {
vi.clearAllMocks()
})
describe('doctorApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/doctors 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await doctorApi.list({ page: 1, page_size: 10, search: '王', department: '内科' })
expect(mockGet).toHaveBeenCalledWith('/health/doctors', {
params: { page: 1, page_size: 10, search: '王', department: '内科' },
})
})
it('get 应调用 GET /health/doctors/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await doctorApi.get('d-001')
expect(mockGet).toHaveBeenCalledWith('/health/doctors/d-001')
})
it('create 应调用 POST /health/doctors 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '王医生', department: '内科', title: '主任医师' }
await doctorApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/doctors', req)
})
it('update 应调用 PUT /health/doctors/:id 并传递请求体含 version', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { title: '副主任医师', version: 1 }
await doctorApi.update('d-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/doctors/d-001', req)
})
it('delete 应调用 DELETE /health/doctors/:id', async () => {
mockDelete.mockResolvedValue(undefined)
await doctorApi.delete('d-001', 1)
expect(mockDelete).toHaveBeenCalledWith('/health/doctors/d-001')
})
})

View File

@@ -0,0 +1,83 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Doctor {
id: string;
user_id?: string;
name: string;
department?: string;
title?: string;
specialty?: string;
license_number?: string;
bio?: string;
online_status: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateDoctorReq {
user_id?: string;
name: string;
department?: string;
title?: string;
specialty?: string;
license_number?: string;
bio?: string;
}
export interface UpdateDoctorReq {
name?: string;
department?: string;
title?: string;
specialty?: string;
license_number?: string;
bio?: string;
online_status?: string;
}
// --- API ---
export const doctorApi = {
list: async (params: {
page?: number;
page_size?: number;
search?: string;
department?: string;
title?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Doctor>;
}>('/health/doctors', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: Doctor;
}>(`/health/doctors/${id}`);
return data.data;
},
create: async (req: CreateDoctorReq) => {
const { data } = await client.post<{
success: boolean;
data: Doctor;
}>('/health/doctors', req);
return data.data;
},
update: async (id: string, req: UpdateDoctorReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: Doctor;
}>(`/health/doctors/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
await client.delete(`/health/doctors/${id}`, { data: { version } });
},
};

View File

@@ -0,0 +1,109 @@
import client from '../client';
// --- Types ---
export interface FamilyMember {
id: string;
patient_id: string;
name: string;
relationship: string;
phone?: string;
birth_date?: string;
notes?: string;
user_id?: string;
consent_status: string;
access_level: string;
consented_at?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface FamilyPatientSummary {
family_member_id: string;
patient_id: string;
patient_name: string;
relationship: string;
consent_status: string;
access_level: string;
consented_at?: string;
}
export interface FamilyHealthSummary {
patient_id: string;
patient_name: string;
latest_vital_signs?: Record<string, unknown>;
active_care_plan?: Record<string, unknown>;
recent_alerts_count: number;
next_appointment?: Record<string, unknown>;
}
export interface GrantAccessReq {
access_level: string;
}
// --- Constants ---
export const CONSENT_STATUS_OPTIONS = [
{ label: '已同意', value: 'granted' },
{ label: '待确认', value: 'pending' },
{ label: '已撤销', value: 'revoked' },
{ label: '已过期', value: 'expired' },
];
export const ACCESS_LEVEL_OPTIONS = [
{ label: '完全访问', value: 'full' },
{ label: '只读', value: 'read_only' },
{ label: '摘要', value: 'summary' },
];
export const CONSENT_STATUS_COLOR: Record<string, string> = {
granted: 'green',
pending: 'orange',
revoked: 'red',
expired: 'default',
};
export const ACCESS_LEVEL_LABEL: Record<string, string> = Object.fromEntries(
ACCESS_LEVEL_OPTIONS.map((o) => [o.value, o.label]),
);
export const CONSENT_STATUS_LABEL: Record<string, string> = Object.fromEntries(
CONSENT_STATUS_OPTIONS.map((o) => [o.value, o.label]),
);
// --- API ---
export const familyProxyApi = {
grantAccess: async (patientId: string, familyMemberId: string, req: GrantAccessReq, version: number) => {
const { data } = await client.post<{
success: boolean;
data: FamilyMember;
}>(`/health/patients/${patientId}/family-members/${familyMemberId}/grant-access`, { ...req, version });
return data.data;
},
revokeAccess: async (patientId: string, familyMemberId: string, version: number) => {
const { data } = await client.put<{
success: boolean;
data: FamilyMember;
}>(`/health/patients/${patientId}/family-members/${familyMemberId}/revoke-access`, { version });
return data.data;
},
listMyPatients: async () => {
const { data } = await client.get<{
success: boolean;
data: FamilyPatientSummary[];
}>('/health/family/patients');
return data.data;
},
getHealthSummary: async (patientId: string) => {
const { data } = await client.get<{
success: boolean;
data: FamilyHealthSummary;
}>(`/health/family/patients/${patientId}/health-summary`);
return data.data;
},
};

View File

@@ -0,0 +1,97 @@
/**
* followUp API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { followUpApi } from './followUp'
beforeEach(() => {
vi.clearAllMocks()
})
describe('followUpApi - Tasks', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listTasks 应调用 GET /health/follow-up-tasks 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await followUpApi.listTasks({ page: 1, page_size: 20, patient_id: 'p-001', status: 'pending' })
expect(mockGet).toHaveBeenCalledWith('/health/follow-up-tasks', {
params: { page: 1, page_size: 20, patient_id: 'p-001', status: 'pending' },
})
})
it('getTask 应调用 GET /health/follow-up-tasks/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await followUpApi.getTask('task-001')
expect(mockGet).toHaveBeenCalledWith('/health/follow-up-tasks/task-001')
})
it('createTask 应调用 POST /health/follow-up-tasks', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { patient_id: 'p-001', follow_up_type: 'phone', planned_date: '2026-05-10' }
await followUpApi.createTask(req)
expect(mockPost).toHaveBeenCalledWith('/health/follow-up-tasks', req)
})
it('updateTask 应调用 PUT /health/follow-up-tasks/:id 并传递 version', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { status: 'completed', version: 1 }
await followUpApi.updateTask('task-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/follow-up-tasks/task-001', req)
})
it('deleteTask 应调用 DELETE /health/follow-up-tasks/:id 并在 body 传递 version', async () => {
mockDelete.mockResolvedValue(undefined)
await followUpApi.deleteTask('task-001', 2)
expect(mockDelete).toHaveBeenCalledWith('/health/follow-up-tasks/task-001', {
data: { version: 2 },
})
})
})
describe('followUpApi - Records', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listRecords 应调用 GET /health/follow-up-records 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await followUpApi.listRecords({ page: 1, page_size: 10, task_id: 'task-001' })
expect(mockGet).toHaveBeenCalledWith('/health/follow-up-records', {
params: { page: 1, page_size: 10, task_id: 'task-001' },
})
})
it('createRecord 应调用 POST /health/follow-up-tasks/:taskId/records 并注入 task_id', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = {
executed_date: '2026-05-10',
result: '已完成',
patient_condition: '良好',
}
await followUpApi.createRecord('task-001', req)
expect(mockPost).toHaveBeenCalledWith('/health/follow-up-tasks/task-001/records', {
...req,
task_id: 'task-001',
})
})
})

View File

@@ -0,0 +1,133 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface FollowUpTask {
id: string;
patient_id: string;
assigned_to?: string;
patient_name?: string;
assigned_to_name?: string;
follow_up_type: string;
planned_date: string;
status: string;
content_template?: string;
related_appointment_id?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateFollowUpTaskReq {
patient_id: string;
assigned_to?: string;
follow_up_type: string;
planned_date: string;
content_template?: string;
related_appointment_id?: string;
}
export interface UpdateFollowUpTaskReq {
assigned_to?: string;
follow_up_type?: string;
planned_date?: string;
content_template?: string;
status?: string;
}
export interface FollowUpRecord {
id: string;
task_id: string;
executed_by?: string;
executed_date: string;
result?: string;
patient_condition?: string;
medical_advice?: string;
next_follow_up_date?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateFollowUpRecordReq {
task_id: string;
executed_by?: string;
executed_date: string;
result?: string;
patient_condition?: string;
medical_advice?: string;
next_follow_up_date?: string;
}
// --- API ---
export const followUpApi = {
// Tasks
listTasks: async (params: {
page?: number;
page_size?: number;
patient_id?: string;
assigned_to?: string;
status?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<FollowUpTask>;
}>('/health/follow-up-tasks', { params });
return data.data;
},
getTask: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: FollowUpTask;
}>(`/health/follow-up-tasks/${id}`);
return data.data;
},
createTask: async (req: CreateFollowUpTaskReq) => {
const { data } = await client.post<{
success: boolean;
data: FollowUpTask;
}>('/health/follow-up-tasks', req);
return data.data;
},
updateTask: async (
id: string,
req: UpdateFollowUpTaskReq & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: FollowUpTask;
}>(`/health/follow-up-tasks/${id}`, req);
return data.data;
},
deleteTask: async (id: string, version: number) => {
await client.delete(`/health/follow-up-tasks/${id}`, {
data: { version },
});
},
// Records
listRecords: async (params: {
page?: number;
page_size?: number;
task_id?: string;
patient_id?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<FollowUpRecord>;
}>('/health/follow-up-records', { params });
return data.data;
},
createRecord: async (taskId: string, req: Omit<CreateFollowUpRecordReq, 'task_id'>) => {
const { data } = await client.post<{
success: boolean;
data: FollowUpRecord;
}>(`/health/follow-up-tasks/${taskId}/records`, { ...req, task_id: taskId });
return data.data;
},
};

View File

@@ -0,0 +1,75 @@
/**
* followUpTemplates API 契约测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { followUpTemplateApi } from './followUpTemplates'
beforeEach(() => {
vi.clearAllMocks()
})
describe('followUpTemplateApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/follow-up-templates 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await followUpTemplateApi.list({ page: 1, page_size: 10, follow_up_type: 'phone', status: 'active' })
expect(mockGet).toHaveBeenCalledWith('/health/follow-up-templates', {
params: { page: 1, page_size: 10, follow_up_type: 'phone', status: 'active' },
})
})
it('get 应调用 GET /health/follow-up-templates/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await followUpTemplateApi.get('tpl-001')
expect(mockGet).toHaveBeenCalledWith('/health/follow-up-templates/tpl-001')
})
it('create 应调用 POST /health/follow-up-templates 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = {
name: '电话随访模板',
follow_up_type: 'phone',
fields: [
{ label: '患者状态', field_key: 'patient_status', field_type: 'select', required: true, options: '良好,一般,较差' },
],
}
await followUpTemplateApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/follow-up-templates', req)
})
it('update 应调用 PUT /health/follow-up-templates/:id 并传递请求体', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { name: '更新后模板', status: 'active', version: 1 }
await followUpTemplateApi.update('tpl-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/follow-up-templates/tpl-001', req)
})
it('delete 应调用 DELETE /health/follow-up-templates/:id 并在 body 传递 version', async () => {
mockDelete.mockResolvedValue(undefined)
await followUpTemplateApi.delete('tpl-001', 2)
expect(mockDelete).toHaveBeenCalledWith('/health/follow-up-templates/tpl-001', {
data: { version: 2 },
})
})
})

View File

@@ -0,0 +1,119 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
export type FollowUpType = 'phone' | 'outpatient' | 'home_visit' | 'online' | 'wechat';
export type TemplateStatus = 'active' | 'draft' | 'archived';
export interface TemplateField {
id: string;
template_id: string;
label: string;
field_key: string;
field_type: string;
required: boolean;
options?: string;
placeholder?: string;
validation?: string;
sort_order: number;
created_at: string;
updated_at: string;
version: number;
}
export interface TemplateFieldReq {
label: string;
field_key: string;
field_type: string;
required?: boolean;
options?: string;
placeholder?: string;
validation?: string;
sort_order?: number;
}
export interface FollowUpTemplate {
id: string;
name: string;
description?: string;
follow_up_type: string;
applicable_scope?: string;
status: string;
fields: TemplateField[];
created_at: string;
updated_at: string;
version: number;
}
export interface FollowUpTemplateListItem {
id: string;
name: string;
description?: string;
follow_up_type: string;
status: string;
field_count: number;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateTemplateReq {
name: string;
description?: string;
follow_up_type: string;
applicable_scope?: string;
fields: TemplateFieldReq[];
}
export interface UpdateTemplateReq {
name?: string;
description?: string;
follow_up_type?: string;
applicable_scope?: string;
status?: string;
fields?: TemplateFieldReq[];
}
export const followUpTemplateApi = {
list: async (params?: {
page?: number;
page_size?: number;
follow_up_type?: string;
status?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<FollowUpTemplateListItem>;
}>('/health/follow-up-templates', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: FollowUpTemplate;
}>(`/health/follow-up-templates/${id}`);
return data.data;
},
create: async (req: CreateTemplateReq) => {
const { data } = await client.post<{
success: boolean;
data: FollowUpTemplate;
}>('/health/follow-up-templates', req);
return data.data;
},
update: async (id: string, req: UpdateTemplateReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: FollowUpTemplate;
}>(`/health/follow-up-templates/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
await client.delete(`/health/follow-up-templates/${id}`, {
data: { version },
});
},
};

View File

@@ -0,0 +1,135 @@
/**
* healthData API 契约测试(体征/化验报告/健康记录/趋势/日常监测)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { healthDataApi } from './healthData'
beforeEach(() => {
vi.clearAllMocks()
})
describe('healthDataApi - Vital Signs', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listVitalSigns 应调用 GET /health/patients/:id/vital-signs 并传递分页', async () => {
mockGet.mockResolvedValue(fakeRes)
await healthDataApi.listVitalSigns('p-001', { page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/vital-signs', {
params: { page: 1, page_size: 10 },
})
})
it('createVitalSigns 应调用 POST /health/patients/:id/vital-signs', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { record_date: '2026-05-03', systolic_bp_morning: 120, diastolic_bp_morning: 80 }
await healthDataApi.createVitalSigns('p-001', req)
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/vital-signs', req)
})
it('updateVitalSigns 应调用 PUT /health/patients/:pid/vital-signs/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { systolic_bp_morning: 125, version: 1 }
await healthDataApi.updateVitalSigns('p-001', 'vs-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/patients/p-001/vital-signs/vs-001', req)
})
it('deleteVitalSigns 应调用 DELETE /health/patients/:pid/vital-signs/:id', async () => {
mockDelete.mockResolvedValue(undefined)
await healthDataApi.deleteVitalSigns('p-001', 'vs-001')
expect(mockDelete).toHaveBeenCalledWith('/health/patients/p-001/vital-signs/vs-001')
})
})
describe('healthDataApi - Lab Reports', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listLabReports 应调用 GET /health/patients/:id/lab-reports', async () => {
mockGet.mockResolvedValue(fakeRes)
await healthDataApi.listLabReports('p-001', { page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/lab-reports', {
params: { page: 1, page_size: 10 },
})
})
it('createLabReport 应调用 POST /health/patients/:id/lab-reports', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { report_date: '2026-05-03', report_type: 'blood_test' }
await healthDataApi.createLabReport('p-001', req)
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/lab-reports', req)
})
it('reviewLabReport 应调用 PUT /health/patients/:pid/lab-reports/:rid/review', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { version: 1, doctor_notes: '指标正常' }
await healthDataApi.reviewLabReport('p-001', 'lr-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/patients/p-001/lab-reports/lr-001/review', req)
})
it('deleteLabReport 应调用 DELETE /health/patients/:pid/lab-reports/:id', async () => {
mockDelete.mockResolvedValue(undefined)
await healthDataApi.deleteLabReport('p-001', 'lr-001')
expect(mockDelete).toHaveBeenCalledWith('/health/patients/p-001/lab-reports/lr-001')
})
})
describe('healthDataApi - Health Records', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listHealthRecords 应调用 GET /health/patients/:id/health-records', async () => {
mockGet.mockResolvedValue(fakeRes)
await healthDataApi.listHealthRecords('p-001', { page: 1, page_size: 10 })
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/health-records', {
params: { page: 1, page_size: 10 },
})
})
it('createHealthRecord 应调用 POST /health/patients/:id/health-records', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { record_type: 'checkup', record_date: '2026-05-03', content: '体检结果正常' }
await healthDataApi.createHealthRecord('p-001', req)
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/health-records', req)
})
})
describe('healthDataApi - Trends', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listTrends 应调用 GET /health/patients/:id/trends', async () => {
mockGet.mockResolvedValue(fakeRes)
await healthDataApi.listTrends('p-001')
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/trends')
})
it('getIndicatorTimeseries 应调用 GET /health/patients/:id/trends/:indicator 并编码', async () => {
mockGet.mockResolvedValue(fakeRes)
await healthDataApi.getIndicatorTimeseries('p-001', 'blood_pressure/systolic')
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/trends/blood_pressure%2Fsystolic')
})
})

View File

@@ -0,0 +1,304 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface VitalSigns {
id: string;
patient_id: string;
record_date: string;
systolic_bp_morning?: number;
diastolic_bp_morning?: number;
systolic_bp_evening?: number;
diastolic_bp_evening?: number;
heart_rate?: number;
weight?: number;
blood_sugar?: number;
water_intake_ml?: number;
urine_output_ml?: number;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateVitalSignsReq {
record_date: string;
systolic_bp_morning?: number;
diastolic_bp_morning?: number;
systolic_bp_evening?: number;
diastolic_bp_evening?: number;
heart_rate?: number;
weight?: number;
blood_sugar?: number;
water_intake_ml?: number;
urine_output_ml?: number;
notes?: string;
}
export interface LabReport {
id: string;
patient_id: string;
report_date: string;
report_type: string;
items?: unknown;
image_urls?: string[];
doctor_notes?: string;
source?: string;
status: string;
reviewed_by?: string;
reviewed_at?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateLabReportReq {
report_date: string;
report_type: string;
items?: unknown;
image_urls?: string[];
doctor_notes?: string;
}
export interface HealthRecord {
id: string;
patient_id: string;
record_type: string;
record_date: string;
overall_assessment?: string;
report_file_url?: string;
source?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateHealthRecordReq {
record_type: string;
record_date: string;
overall_assessment?: string;
report_file_url?: string;
}
export interface DailyMonitoring {
id: string;
patient_id: string;
record_date: string;
morning_bp_systolic?: number;
morning_bp_diastolic?: number;
evening_bp_systolic?: number;
evening_bp_diastolic?: number;
weight?: number;
blood_sugar?: number;
fluid_intake?: number;
urine_output?: number;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateDailyMonitoringReq {
patient_id: string;
record_date: string;
morning_bp_systolic?: number;
morning_bp_diastolic?: number;
evening_bp_systolic?: number;
evening_bp_diastolic?: number;
weight?: number;
blood_sugar?: number;
fluid_intake?: number;
urine_output?: number;
notes?: string;
}
export interface TrendData {
id: string;
patient_id: string;
indicator: string;
trend_data: { date: string; value: number }[];
generated_at: string;
}
// --- API ---
export const healthDataApi = {
// Vital Signs
listVitalSigns: async (
patientId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<VitalSigns>;
}>(`/health/patients/${patientId}/vital-signs`, { params });
return data.data;
},
createVitalSigns: async (patientId: string, req: CreateVitalSignsReq) => {
const { data } = await client.post<{
success: boolean;
data: VitalSigns;
}>(`/health/patients/${patientId}/vital-signs`, req);
return data.data;
},
updateVitalSigns: async (
patientId: string,
id: string,
req: Partial<CreateVitalSignsReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: VitalSigns;
}>(`/health/patients/${patientId}/vital-signs/${id}`, req);
return data.data;
},
deleteVitalSigns: async (patientId: string, id: string) => {
await client.delete(`/health/patients/${patientId}/vital-signs/${id}`);
},
// Lab Reports
listLabReports: async (
patientId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<LabReport>;
}>(`/health/patients/${patientId}/lab-reports`, { params });
return data.data;
},
createLabReport: async (patientId: string, req: CreateLabReportReq) => {
const { data } = await client.post<{
success: boolean;
data: LabReport;
}>(`/health/patients/${patientId}/lab-reports`, req);
return data.data;
},
updateLabReport: async (
patientId: string,
id: string,
req: Partial<CreateLabReportReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: LabReport;
}>(`/health/patients/${patientId}/lab-reports/${id}`, req);
return data.data;
},
deleteLabReport: async (patientId: string, id: string) => {
await client.delete(`/health/patients/${patientId}/lab-reports/${id}`);
},
reviewLabReport: async (patientId: string, reportId: string, req: { version: number; doctor_notes?: string }) => {
const { data } = await client.put<{
success: boolean;
data: Record<string, unknown>;
}>(`/health/patients/${patientId}/lab-reports/${reportId}/review`, req);
return data.data;
},
// Health Records
listHealthRecords: async (
patientId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<HealthRecord>;
}>(`/health/patients/${patientId}/health-records`, { params });
return data.data;
},
createHealthRecord: async (
patientId: string,
req: CreateHealthRecordReq,
) => {
const { data } = await client.post<{
success: boolean;
data: HealthRecord;
}>(`/health/patients/${patientId}/health-records`, req);
return data.data;
},
updateHealthRecord: async (
patientId: string,
id: string,
req: Partial<CreateHealthRecordReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: HealthRecord;
}>(`/health/patients/${patientId}/health-records/${id}`, req);
return data.data;
},
deleteHealthRecord: async (patientId: string, id: string) => {
await client.delete(`/health/patients/${patientId}/health-records/${id}`);
},
// Trends
listTrends: async (patientId: string) => {
const { data } = await client.get<{
success: boolean;
data: TrendData[];
}>(`/health/patients/${patientId}/trends`);
return data.data;
},
generateTrend: async (patientId: string, req: { indicator: string; start_date?: string; end_date?: string }) => {
const { data } = await client.post<{
success: boolean;
data: TrendData;
}>(`/health/patients/${patientId}/trends/generate`, req);
return data.data;
},
getIndicatorTimeseries: async (patientId: string, indicator: string) => {
const { data } = await client.get<{
success: boolean;
data: { date: string; value: number }[];
}>(`/health/patients/${patientId}/trends/${encodeURIComponent(indicator)}`);
return data.data;
},
// Daily Monitoring
listDailyMonitoring: async (
patientId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<DailyMonitoring>;
}>(`/health/patients/${patientId}/daily-monitoring`, { params });
return data.data;
},
createDailyMonitoring: async (req: CreateDailyMonitoringReq) => {
const { data } = await client.post<{
success: boolean;
data: DailyMonitoring;
}>('/health/daily-monitoring', req);
return data.data;
},
updateDailyMonitoring: async (
id: string,
req: Partial<CreateDailyMonitoringReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: DailyMonitoring;
}>(`/health/daily-monitoring/${id}`, req);
return data.data;
},
deleteDailyMonitoring: async (id: string, version: number) => {
await client.delete(`/health/daily-monitoring/${id}`, { data: { version } });
},
};

View File

@@ -0,0 +1,208 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// ---------------------------------------------------------------------------
// 媒体文件类型
// ---------------------------------------------------------------------------
export interface MediaItem {
id: string;
tenant_id: string;
folder_id?: string;
filename: string;
storage_path: string;
thumbnail_path?: string;
content_type: string;
file_size: number;
width?: number;
height?: number;
alt_text?: string;
is_public: boolean;
created_at: string;
updated_at: string;
created_by?: string;
updated_by?: string;
version: number;
}
export interface MediaListParams {
page?: number;
page_size?: number;
folder_id?: string;
content_type?: string;
keyword?: string;
is_public?: boolean;
}
export interface UpdateMediaReq {
filename?: string;
alt_text?: string;
is_public?: boolean;
folder_id?: string;
version: number;
}
export interface MoveMediaReq {
folder_id?: string;
version: number;
}
export interface CropReq {
x: number;
y: number;
width: number;
height: number;
version: number;
}
// ---------------------------------------------------------------------------
// 文件夹类型
// ---------------------------------------------------------------------------
export interface FolderItem {
id: string;
tenant_id: string;
name: string;
parent_id?: string;
sort_order: number;
children: FolderItem[];
item_count: number;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateFolderReq {
name: string;
parent_id?: string;
sort_order?: number;
}
export interface UpdateFolderReq {
name?: string;
parent_id?: string;
sort_order?: number;
version: number;
}
// ---------------------------------------------------------------------------
// 媒体文件 API
// ---------------------------------------------------------------------------
export const mediaApi = {
/** 分页查询媒体文件列表 */
list: async (params: MediaListParams) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<MediaItem>;
}>('/health/media', { params });
return data.data;
},
/** 上传媒体文件multipart/form-data */
upload: async (formData: FormData) => {
const { data } = await client.post<{
success: boolean;
data: MediaItem;
}>('/health/media/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data.data;
},
/** 获取单个媒体文件详情 */
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: MediaItem;
}>(`/health/media/${id}`);
return data.data;
},
/** 更新媒体文件信息 */
update: async (id: string, req: UpdateMediaReq) => {
const { data } = await client.put<{
success: boolean;
data: MediaItem;
}>(`/health/media/${id}`, req);
return data.data;
},
/** 删除媒体文件 */
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/media/${id}`, { data: { version } });
return data.data;
},
/** 移动媒体文件到指定文件夹 */
move: async (id: string, req: MoveMediaReq) => {
const { data } = await client.post<{
success: boolean;
data: MediaItem;
}>(`/health/media/${id}/move`, req);
return data.data;
},
/** 批量删除媒体文件 */
batchDelete: async (ids: string[]) => {
const { data } = await client.post<{
success: boolean;
data: null;
}>('/health/media/batch-delete', { ids });
return data.data;
},
/** 裁剪媒体文件 */
crop: async (id: string, req: CropReq) => {
const { data } = await client.post<{
success: boolean;
data: MediaItem;
}>(`/health/media/${id}/crop`, req);
return data.data;
},
};
// ---------------------------------------------------------------------------
// 文件夹 API
// ---------------------------------------------------------------------------
export const mediaFolderApi = {
/** 获取文件夹树形结构 */
tree: async () => {
const { data } = await client.get<{
success: boolean;
data: FolderItem[];
}>('/health/media-folders');
return data.data;
},
/** 创建文件夹 */
create: async (req: CreateFolderReq) => {
const { data } = await client.post<{
success: boolean;
data: FolderItem;
}>('/health/media-folders', req);
return data.data;
},
/** 更新文件夹 */
update: async (id: string, req: UpdateFolderReq) => {
const { data } = await client.put<{
success: boolean;
data: FolderItem;
}>(`/health/media-folders/${id}`, req);
return data.data;
},
/** 删除文件夹 */
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/media-folders/${id}`, { data: { version } });
return data.data;
},
};

View File

@@ -0,0 +1,111 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface MedicationRecord {
id: string;
patient_id: string;
medication_name: string;
generic_name?: string;
dosage?: string;
unit?: string;
frequency?: string;
route?: string;
start_date?: string;
end_date?: string;
is_current: boolean;
prescribed_by?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateMedicationRecordReq {
patient_id: string;
medication_name: string;
generic_name?: string;
dosage?: string;
unit?: string;
frequency?: string;
route?: string;
start_date?: string;
end_date?: string;
is_current?: boolean;
prescribed_by?: string;
notes?: string;
}
export interface UpdateMedicationRecordReq {
medication_name?: string;
generic_name?: string;
dosage?: string;
unit?: string;
frequency?: string;
route?: string;
start_date?: string;
end_date?: string;
is_current?: boolean;
prescribed_by?: string;
notes?: string;
}
// --- Constants ---
export const FREQUENCY_OPTIONS = [
{ label: '每日一次', value: 'QD' },
{ label: '每日两次', value: 'BID' },
{ label: '每日三次', value: 'TID' },
{ label: '每晚一次', value: 'QN' },
{ label: '每周一次', value: 'QW' },
{ label: '必要时', value: 'PRN' },
];
export const ROUTE_OPTIONS = [
{ label: '口服', value: 'oral' },
{ label: '静脉注射', value: 'iv' },
{ label: '皮下注射', value: 'sc' },
{ label: '外用', value: 'topical' },
{ label: '吸入', value: 'inhalation' },
];
// --- API ---
export const medicationRecordApi = {
list: async (patientId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<MedicationRecord>;
}>(`/health/patients/${patientId}/medications`, { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: MedicationRecord;
}>(`/health/medications/${id}`);
return data.data;
},
create: async (req: CreateMedicationRecordReq) => {
const { data } = await client.post<{
success: boolean;
data: MedicationRecord;
}>('/health/medications', req);
return data.data;
},
update: async (id: string, req: UpdateMedicationRecordReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: MedicationRecord;
}>(`/health/medications/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
await client.delete(`/health/medications/${id}`, { data: { version } });
},
};

View File

@@ -0,0 +1,75 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface MedicationReminder {
id: string;
patient_id: string;
medication_name: string;
dosage?: string;
frequency: string;
reminder_times: unknown;
start_date?: string;
end_date?: string;
is_active: boolean;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateMedicationReminderReq {
patient_id: string;
medication_name: string;
dosage?: string;
frequency?: string;
reminder_times?: unknown;
start_date?: string;
end_date?: string;
is_active?: boolean;
notes?: string;
}
export interface UpdateMedicationReminderReq {
medication_name?: string;
dosage?: string;
frequency?: string;
reminder_times?: unknown;
start_date?: string;
end_date?: string;
is_active?: boolean;
notes?: string;
}
// --- API ---
export const medicationReminderApi = {
list: async (patientId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<MedicationReminder>;
}>(`/health/patients/${patientId}/medication-reminders`, { params });
return data.data;
},
create: async (req: CreateMedicationReminderReq) => {
const { data } = await client.post<{
success: boolean;
data: MedicationReminder;
}>('/health/medication-reminders', req);
return data.data;
},
update: async (id: string, req: UpdateMedicationReminderReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: MedicationReminder;
}>(`/health/medication-reminders/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
await client.delete(`/health/medication-reminders/${id}`, { data: { version } });
},
};

View File

@@ -0,0 +1,73 @@
import client from '../client';
// --- Types ---
export interface OAuthClient {
id: string;
client_id: string;
client_name: string;
scopes: string[];
rate_limit_per_minute: number;
is_active: boolean;
token_lifetime_seconds: number;
created_at: string;
version: number;
}
export interface OAuthClientDetail extends OAuthClient {
tenant_id: string;
client_secret: string;
allowed_patient_ids?: string[];
}
export interface CreateOAuthClientReq {
client_name: string;
scopes: string[];
allowed_patient_ids?: string[];
rate_limit_per_minute?: number;
token_lifetime_seconds?: number;
}
export interface UpdateOAuthClientReq {
client_name?: string;
scopes?: string[];
allowed_patient_ids?: string[] | null;
rate_limit_per_minute?: number;
is_active?: boolean;
token_lifetime_seconds?: number;
version: number;
}
export interface RegenerateSecretResp {
client_id: string;
client_secret: string;
}
// --- FHIR Scope ---
export const FHIR_SCOPE_OPTIONS = [
{ value: 'Patient.read', label: 'Patient.read — 读取患者' },
{ value: 'Observation.read', label: 'Observation.read — 读取体征' },
{ value: 'Device.read', label: 'Device.read — 读取设备' },
{ value: 'DiagnosticReport.read', label: 'DiagnosticReport.read — 读取诊断报告' },
{ value: 'Encounter.read', label: 'Encounter.read — 读取就诊记录' },
{ value: 'Practitioner.read', label: 'Practitioner.read — 读取医护' },
{ value: 'Appointment.read', label: 'Appointment.read — 读取预约' },
{ value: 'Task.read', label: 'Task.read — 读取随访任务' },
];
// --- API ---
export const oauthClientApi = {
list: () =>
client.get('/health/oauth/clients').then((r) => r.data.data as OAuthClient[]),
create: (data: CreateOAuthClientReq) =>
client.post('/health/oauth/clients', data).then((r) => r.data.data as OAuthClientDetail),
update: (id: string, data: UpdateOAuthClientReq) =>
client.put(`/health/oauth/clients/${id}`, data).then((r) => r.data.data as OAuthClient),
delete: (id: string) =>
client.delete(`/health/oauth/clients/${id}`).then((r) => r.data),
regenerateSecret: (id: string) =>
client.post(`/health/oauth/clients/${id}/regenerate-secret`).then((r) => r.data.data as RegenerateSecretResp),
};

View File

@@ -0,0 +1,126 @@
/**
* patients API 契约测试
*
* 验证 patientApi 各函数调用正确的 HTTP 方法、URL 路径和参数序列化。
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { patientApi } from './patients'
beforeEach(() => {
vi.clearAllMocks()
})
describe('patientApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('list 应调用 GET /health/patients 并传递查询参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await patientApi.list({ page: 1, page_size: 20, search: '张三', status: 'active' })
expect(mockGet).toHaveBeenCalledWith('/health/patients', {
params: { page: 1, page_size: 20, search: '张三', status: 'active' },
})
})
it('list 应支持 tag_id 过滤参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await patientApi.list({ tag_id: 'tag-001' })
expect(mockGet).toHaveBeenCalledWith('/health/patients', {
params: { tag_id: 'tag-001' },
})
})
it('get 应调用 GET /health/patients/:id', async () => {
mockGet.mockResolvedValue(fakeRes)
await patientApi.get('p-001')
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001')
})
it('create 应调用 POST /health/patients 并传递请求体', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '李四', gender: 'male', birth_date: '1990-01-01' }
await patientApi.create(req)
expect(mockPost).toHaveBeenCalledWith('/health/patients', req)
})
it('update 应调用 PUT /health/patients/:id 并传递请求体含 version', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { name: '李四改', version: 2 }
await patientApi.update('p-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/patients/p-001', req)
})
it('delete 应调用 DELETE /health/patients/:id 并在 body 中传递 version', async () => {
mockDelete.mockResolvedValue(undefined)
await patientApi.delete('p-001', 3)
expect(mockDelete).toHaveBeenCalledWith('/health/patients/p-001', {
data: { version: 3 },
})
})
it('manageTags 应调用 POST /health/patients/:id/tags 并传递 tag_ids', async () => {
mockPost.mockResolvedValue(undefined)
await patientApi.manageTags('p-001', ['tag-1', 'tag-2'])
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/tags', {
tag_ids: ['tag-1', 'tag-2'],
})
})
it('listFamilyMembers 应调用 GET /health/patients/:id/family-members', async () => {
mockGet.mockResolvedValue(fakeRes)
await patientApi.listFamilyMembers('p-001')
expect(mockGet).toHaveBeenCalledWith('/health/patients/p-001/family-members')
})
it('createFamilyMember 应调用 POST /health/patients/:id/family-members', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '家属A', relationship: 'spouse', phone: '13800138000' }
await patientApi.createFamilyMember('p-001', req)
expect(mockPost).toHaveBeenCalledWith('/health/patients/p-001/family-members', req)
})
it('updateFamilyMember 应调用 PUT /health/patients/:pid/family-members/:mid', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { name: '家属A改', version: 1 }
await patientApi.updateFamilyMember('p-001', 'fm-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/patients/p-001/family-members/fm-001', req)
})
it('deleteFamilyMember 应调用 DELETE /health/patients/:pid/family-members/:mid', async () => {
mockDelete.mockResolvedValue(undefined)
await patientApi.deleteFamilyMember('p-001', 'fm-001')
expect(mockDelete).toHaveBeenCalledWith('/health/patients/p-001/family-members/fm-001')
})
it('listTags 应调用 GET /health/patient-tags', async () => {
mockGet.mockResolvedValue(fakeRes)
await patientApi.listTags()
expect(mockGet).toHaveBeenCalledWith('/health/patient-tags')
})
})

View File

@@ -0,0 +1,175 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface PatientListItem {
id: string;
name: string;
gender?: string;
birth_date?: string;
blood_type?: string;
status: string;
verification_status: string;
source?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface PatientDetail {
id: string;
user_id?: string;
name: string;
gender?: string;
birth_date?: string;
blood_type?: string;
id_number?: string;
allergy_history?: string;
medical_history_summary?: string;
emergency_contact_name?: string;
emergency_contact_phone?: string;
status: string;
verification_status: string;
source?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreatePatientReq {
name: string;
gender?: string;
birth_date?: string;
blood_type?: string;
id_number?: string;
allergy_history?: string;
medical_history_summary?: string;
emergency_contact_name?: string;
emergency_contact_phone?: string;
source?: string;
notes?: string;
}
export interface UpdatePatientReq extends Partial<CreatePatientReq> {
status?: string;
verification_status?: string;
}
export interface FamilyMember {
id: string;
name: string;
relationship: string;
phone?: string;
id_number?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateFamilyMemberReq {
name: string;
relationship: string;
phone?: string;
id_number?: string;
notes?: string;
}
export interface TagItem {
id: string;
name: string;
color: string | null;
description: string | null;
}
// --- API ---
export const patientApi = {
list: async (params: {
page?: number;
page_size?: number;
search?: string;
status?: string;
tag_id?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<PatientListItem>;
}>('/health/patients', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: PatientDetail;
}>(`/health/patients/${id}`);
return data.data;
},
create: async (req: CreatePatientReq) => {
const { data } = await client.post<{
success: boolean;
data: PatientDetail;
}>('/health/patients', req);
return data.data;
},
update: async (id: string, req: UpdatePatientReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: PatientDetail;
}>(`/health/patients/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
await client.delete(`/health/patients/${id}`, { data: { version } });
},
manageTags: async (id: string, tagIds: string[]) => {
await client.post(`/health/patients/${id}/tags`, { tag_ids: tagIds });
},
listFamilyMembers: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: FamilyMember[];
}>(`/health/patients/${id}/family-members`);
return data.data;
},
createFamilyMember: async (id: string, req: CreateFamilyMemberReq) => {
const { data } = await client.post<{
success: boolean;
data: FamilyMember;
}>(`/health/patients/${id}/family-members`, req);
return data.data;
},
updateFamilyMember: async (
patientId: string,
memberId: string,
req: Partial<CreateFamilyMemberReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: FamilyMember;
}>(`/health/patients/${patientId}/family-members/${memberId}`, req);
return data.data;
},
deleteFamilyMember: async (patientId: string, memberId: string) => {
await client.delete(
`/health/patients/${patientId}/family-members/${memberId}`,
);
},
listTags: async () => {
const { data } = await client.get<{
success: boolean;
data: TagItem[];
}>('/health/patient-tags');
return data.data;
},
};

View File

@@ -0,0 +1,230 @@
/**
* points API 契约测试(完整覆盖 pointsApi + pointsAdminApi
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
vi.mock('../client', () => ({
default: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}))
import { pointsApi, pointsAdminApi } from './points'
beforeEach(() => {
vi.clearAllMocks()
})
describe('pointsAdminApi', () => {
const fakeRes = { data: { success: true, data: {} } }
it('getPatientAccount 应调用 GET /health/admin/points/patients/:id/account', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsAdminApi.getPatientAccount('p-001')
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/patients/p-001/account')
})
it('listPatientTransactions 应调用 GET 并传递分页参数', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsAdminApi.listPatientTransactions('p-001', { page: 2, page_size: 15 })
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/patients/p-001/transactions', {
params: { page: 2, page_size: 15 },
})
})
})
describe('pointsApi - Rules', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listRules 应调用 GET /health/admin/points/rules', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.listRules()
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/rules')
})
it('createRule 应调用 POST /health/admin/points/rules', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { event_type: 'daily_checkin', name: '每日签到', points_value: 10, daily_cap: 1 }
await pointsApi.createRule(req)
expect(mockPost).toHaveBeenCalledWith('/health/admin/points/rules', req)
})
it('updateRule 应调用 PUT /health/admin/points/rules/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { points_value: 20, version: 1 }
await pointsApi.updateRule('rule-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/admin/points/rules/rule-001', {
data: req,
version: req.version,
})
})
it('deleteRule 应调用 DELETE /health/admin/points/rules/:id', async () => {
mockDelete.mockResolvedValue(undefined)
await pointsApi.deleteRule('rule-001', 2)
expect(mockDelete).toHaveBeenCalledWith('/health/admin/points/rules/rule-001', {
data: { version: 2 },
})
})
})
describe('pointsApi - Products', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listProducts 应调用 GET /health/points/products', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.listProducts()
expect(mockGet).toHaveBeenCalledWith('/health/points/products', { params: undefined })
})
it('createProduct 应调用 POST /health/admin/points/products', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { name: '体检优惠券', product_type: 'service', points_cost: 500, stock: 100 }
await pointsApi.createProduct(req)
expect(mockPost).toHaveBeenCalledWith('/health/admin/points/products', req)
})
it('updateProduct 应调用 PUT /health/admin/points/products/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { points_cost: 600, version: 1 }
await pointsApi.updateProduct('prod-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/admin/points/products/prod-001', {
data: req,
version: req.version,
})
})
it('deleteProduct 应调用 DELETE /health/admin/points/products/:id', async () => {
mockDelete.mockResolvedValue(undefined)
await pointsApi.deleteProduct('prod-001', 1)
expect(mockDelete).toHaveBeenCalledWith('/health/admin/points/products/prod-001', {
data: { version: 1 },
})
})
})
describe('pointsApi - Orders', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listOrders 应调用 GET /health/admin/points/orders', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.listOrders()
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/orders', { params: undefined })
})
it('verifyOrder 应调用 POST /health/points/verify', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { qr_code: 'QR-123456' }
await pointsApi.verifyOrder(req)
expect(mockPost).toHaveBeenCalledWith('/health/points/verify', req)
})
})
describe('pointsApi - Offline Events', () => {
const fakeRes = { data: { success: true, data: {} } }
it('listOfflineEvents 应调用 GET /health/admin/offline-events', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.listOfflineEvents()
expect(mockGet).toHaveBeenCalledWith('/health/admin/offline-events', { params: undefined })
})
it('createOfflineEvent 应调用 POST /health/admin/offline-events', async () => {
mockPost.mockResolvedValue(fakeRes)
const req = { title: '健康讲座', event_date: '2026-05-20', points_reward: 50 }
await pointsApi.createOfflineEvent(req)
expect(mockPost).toHaveBeenCalledWith('/health/admin/offline-events', req)
})
it('updateOfflineEvent 应调用 PUT /health/admin/offline-events/:id', async () => {
mockPut.mockResolvedValue(fakeRes)
const req = { title: '健康讲座(更新)', version: 1 }
await pointsApi.updateOfflineEvent('evt-001', req)
expect(mockPut).toHaveBeenCalledWith('/health/admin/offline-events/evt-001', req)
})
it('deleteOfflineEvent 应调用 DELETE /health/admin/offline-events/:id', async () => {
mockDelete.mockResolvedValue(undefined)
await pointsApi.deleteOfflineEvent('evt-001', 1)
expect(mockDelete).toHaveBeenCalledWith('/health/admin/offline-events/evt-001', {
data: { version: 1 },
})
})
})
describe('pointsApi - Statistics', () => {
const fakeRes = { data: { success: true, data: {} } }
it('getStatistics 应调用 GET /health/admin/points/statistics', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.getStatistics()
expect(mockGet).toHaveBeenCalledWith('/health/admin/points/statistics')
})
it('getPatientStats 应调用 GET /health/admin/statistics/patients', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.getPatientStats()
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/patients')
})
it('getConsultationStats 应调用 GET /health/admin/statistics/consultations', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.getConsultationStats()
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/consultations')
})
it('getFollowUpStats 应调用 GET /health/admin/statistics/follow-ups', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.getFollowUpStats()
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/follow-ups')
})
it('getHealthDataStats 应调用 GET /health/admin/statistics/health-data', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.getHealthDataStats()
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/health-data')
})
it('getDialysisStats 应调用 GET /health/admin/statistics/dialysis', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.getDialysisStats()
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/dialysis')
})
it('getPersonalStats 应调用 GET /health/admin/statistics/personal-stats', async () => {
mockGet.mockResolvedValue(fakeRes)
await pointsApi.getPersonalStats()
expect(mockGet).toHaveBeenCalledWith('/health/admin/statistics/personal-stats')
})
})

View File

@@ -0,0 +1,446 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface PointsRule {
id: string;
event_type: string;
name: string;
description: string | null;
points_value: number;
daily_cap: number;
streak_7d_bonus: number;
streak_14d_bonus: number;
streak_30d_bonus: number;
is_active: boolean;
created_at: string;
updated_at: string;
version: number;
}
export interface CreatePointsRuleReq {
event_type: string;
name: string;
description?: string;
points_value: number;
daily_cap?: number;
streak_7d_bonus?: number;
streak_14d_bonus?: number;
streak_30d_bonus?: number;
}
export interface PointsProduct {
id: string;
name: string;
product_type: string; // physical / service / privilege
points_cost: number;
stock: number;
image_url: string | null;
description: string | null;
is_active: boolean;
sort_order: number;
created_at: string;
updated_at: string;
version: number;
}
export interface CreatePointsProductReq {
name: string;
product_type: string;
points_cost: number;
stock: number;
description?: string;
image_url?: string;
sort_order?: number;
}
export interface PointsOrder {
id: string;
patient_id: string;
product_id: string;
product_name: string | null;
points_cost: number;
status: string; // pending / verified / cancelled / expired
qr_code: string;
verified_by: string | null;
verified_at: string | null;
expires_at: string | null;
notes: string | null;
created_at: string;
updated_at: string;
version: number;
}
export interface VerifyOrderReq {
qr_code: string;
}
export interface OfflineEvent {
id: string;
title: string;
description: string | null;
event_date: string;
start_time: string | null;
end_time: string | null;
location: string | null;
points_reward: number;
max_participants: number;
current_participants: number;
status: string; // draft / published / ongoing / completed / cancelled
image_url: string | null;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateOfflineEventReq {
title: string;
description?: string;
event_date: string;
start_time?: string;
end_time?: string;
location?: string;
points_reward?: number;
max_participants?: number;
status?: string;
image_url?: string;
}
export interface PointsStatistics {
total_issued: number;
total_spent: number;
total_expired: number;
active_accounts: number;
top_earners: Array<{
account_id: string;
patient_id: string;
patient_name: string;
total_earned: number;
}>;
}
export interface PatientStatistics {
total_patients: number;
new_this_month: number;
new_this_week: number;
active_this_month: number;
}
export interface ConsultationStatistics {
total_sessions: number;
pending_reply: number;
avg_response_time_minutes: number | null;
this_month: number;
}
export interface FollowUpStatistics {
total_tasks: number;
completed: number;
pending: number;
overdue: number;
completion_rate: number;
}
export interface PersonalStats {
my_patients: number;
new_patients_this_month: number;
follow_up_rate: number;
consultations_this_month: number;
pending_consultations: number;
vital_signs_report_rate: number;
today_appointments: number;
overdue_follow_ups: number;
today_follow_ups: number;
abnormal_vital_signs: number;
vital_signs_reported: number;
vital_signs_total: number;
pending_lab_reviews: number;
yesterday_my_patients?: number;
yesterday_today_appointments?: number;
yesterday_consultations_this_month?: number;
yesterday_follow_up_rate?: number;
yesterday_today_follow_ups?: number;
yesterday_overdue_follow_ups?: number;
}
export interface OverviewStatistics {
patients: PatientStatistics;
consultations: ConsultationStatistics;
follow_ups: FollowUpStatistics;
points: PointsStatistics;
}
// --- Health Data Statistics Types ---
export interface NameValue {
name: string;
value: number;
}
export interface DialysisStatistics {
total_records: number;
this_month: number;
type_distribution: NameValue[];
complication_rate: number;
avg_ultrafiltration: number | null;
avg_duration: number | null;
pending_review: number;
}
export interface LabReportStatistics {
total_reports: number;
this_month: number;
type_distribution: NameValue[];
abnormal_items: number;
pending_review: number;
reviewed: number;
}
export interface AppointmentStatistics {
total_appointments: number;
this_month: number;
status_distribution: NameValue[];
type_distribution: NameValue[];
cancel_rate: number;
}
export interface DailyReportRate {
date: string;
reported: number;
total: number;
rate: number;
}
export interface VitalSignsReportRate {
total_patients: number;
reported_patients: number;
report_rate: number;
total_records: number;
daily_trend: DailyReportRate[];
}
export interface HealthDataStats {
lab_reports: LabReportStatistics;
appointments: AppointmentStatistics;
vital_signs_report_rate: VitalSignsReportRate;
}
// --- API ---
export interface PointsAccountDetail {
id: string;
patient_id: string;
balance: number;
total_earned: number;
total_spent: number;
total_expired: number;
}
export interface PointsTransactionDetail {
id: string;
account_id: string;
transaction_type: string;
amount: number;
remaining_amount: number;
status: string;
expires_at: string | null;
balance_after: number;
description: string | null;
created_at: string;
}
export const pointsAdminApi = {
getPatientAccount: async (patientId: string) => {
const { data } = await client.get<{
success: boolean;
data: PointsAccountDetail;
}>(`/health/admin/points/patients/${patientId}/account`);
return data.data;
},
listPatientTransactions: async (
patientId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<PointsTransactionDetail>;
}>(`/health/admin/points/patients/${patientId}/transactions`, { params });
return data.data;
},
};
// --- API (original) ---
export const pointsApi = {
// Rules
listRules: async () => {
const { data } = await client.get<{
success: boolean;
data: PointsRule[];
}>('/health/admin/points/rules');
return data.data;
},
createRule: async (req: CreatePointsRuleReq) => {
const { data } = await client.post<{
success: boolean;
data: PointsRule;
}>('/health/admin/points/rules', req);
return data.data;
},
updateRule: async (id: string, req: Partial<CreatePointsRuleReq> & { is_active?: boolean; version: number }) => {
const { data } = await client.put<{
success: boolean;
data: PointsRule;
}>(`/health/admin/points/rules/${id}`, req);
return data.data;
},
deleteRule: async (id: string, version: number) => {
await client.delete(`/health/admin/points/rules/${id}`, {
data: { version },
});
},
// Products
listProducts: async (params?: Record<string, unknown>) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<PointsProduct>;
}>('/health/points/products', { params });
return data.data;
},
createProduct: async (req: CreatePointsProductReq) => {
const { data } = await client.post<{
success: boolean;
data: PointsProduct;
}>('/health/admin/points/products', req);
return data.data;
},
updateProduct: async (id: string, req: Partial<CreatePointsProductReq> & { is_active?: boolean; version: number }) => {
const { version, ...fields } = req;
const { data } = await client.put<{
success: boolean;
data: PointsProduct;
}>(`/health/admin/points/products/${id}`, { data: fields, version });
return data.data;
},
deleteProduct: async (id: string, version: number) => {
await client.delete(`/health/admin/points/products/${id}`, {
data: { version },
});
},
// Orders
listOrders: async (params?: Record<string, unknown>) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<PointsOrder>;
}>('/health/admin/points/orders', { params });
return data.data;
},
verifyOrder: async (req: VerifyOrderReq) => {
const { data } = await client.post<{
success: boolean;
data: PointsOrder;
}>('/health/points/verify', req);
return data.data;
},
// Offline Events
listOfflineEvents: async (params?: Record<string, unknown>) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<OfflineEvent>;
}>('/health/admin/offline-events', { params });
return data.data;
},
createOfflineEvent: async (req: CreateOfflineEventReq) => {
const { data } = await client.post<{
success: boolean;
data: OfflineEvent;
}>('/health/admin/offline-events', req);
return data.data;
},
updateOfflineEvent: async (id: string, req: Partial<CreateOfflineEventReq> & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: OfflineEvent;
}>(`/health/admin/offline-events/${id}`, req);
return data.data;
},
deleteOfflineEvent: async (id: string, version: number) => {
await client.delete(`/health/admin/offline-events/${id}`, {
data: { version },
});
},
// Points Statistics
getStatistics: async (opts?: { silent?: boolean }) => {
const { data } = await client.get<{
success: boolean;
data: PointsStatistics;
}>('/health/admin/points/statistics', { skipGlobalError: opts?.silent });
return data.data;
},
// --- Dashboard Statistics ---
getPatientStats: async (opts?: { silent?: boolean }): Promise<PatientStatistics> => {
const { data } = await client.get<{
success: boolean;
data: PatientStatistics;
}>('/health/admin/statistics/patients', { skipGlobalError: opts?.silent });
return data.data;
},
getConsultationStats: async (opts?: { silent?: boolean }): Promise<ConsultationStatistics> => {
const { data } = await client.get<{
success: boolean;
data: ConsultationStatistics;
}>('/health/admin/statistics/consultations', { skipGlobalError: opts?.silent });
return data.data;
},
getFollowUpStats: async (opts?: { silent?: boolean }): Promise<FollowUpStatistics> => {
const { data } = await client.get<{
success: boolean;
data: FollowUpStatistics;
}>('/health/admin/statistics/follow-ups', { skipGlobalError: opts?.silent });
return data.data;
},
getHealthDataStats: async (opts?: { silent?: boolean }): Promise<HealthDataStats> => {
const { data } = await client.get<{
success: boolean;
data: HealthDataStats;
}>('/health/admin/statistics/health-data', { skipGlobalError: opts?.silent });
return data.data;
},
getDialysisStats: async (opts?: { silent?: boolean }): Promise<DialysisStatistics> => {
const { data } = await client.get<{
success: boolean;
data: DialysisStatistics;
}>('/health/admin/statistics/dialysis', { skipGlobalError: opts?.silent });
return data.data;
},
getPersonalStats: async (opts?: { silent?: boolean }): Promise<PersonalStats> => {
const { data } = await client.get<{
success: boolean;
data: PersonalStats;
}>('/health/admin/statistics/personal-stats', { skipGlobalError: opts?.silent });
return data.data;
},
};

View File

@@ -0,0 +1,247 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Shift {
id: string;
tenant_id: string;
shift_date: string;
period: string;
nurse_id?: string;
status: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
patient_count?: number;
critical_count?: number;
attention_count?: number;
}
export interface PatientAssignment {
id: string;
tenant_id: string;
shift_id: string;
patient_id: string;
care_level: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
patient_name?: string;
}
export interface HandoffLog {
id: string;
tenant_id: string;
from_shift_id: string;
to_shift_id: string;
patient_id: string;
notes?: string;
pending_items?: Record<string, unknown>;
created_at: string;
updated_at: string;
version: number;
patient_name?: string;
}
export interface CreateShiftReq {
shift_date: string;
period: string;
nurse_id?: string;
notes?: string;
}
export interface UpdateShiftReq {
shift_date?: string;
period?: string;
nurse_id?: string;
status?: string;
notes?: string;
}
export interface ListShiftsParams {
page?: number;
page_size?: number;
shift_date?: string;
period?: string;
nurse_id?: string;
status?: string;
}
export interface CreatePatientAssignmentReq {
patient_id: string;
care_level?: string;
notes?: string;
}
export interface BatchAssignReq {
patient_ids: string[];
care_level?: string;
}
export interface UpdatePatientAssignmentReq {
care_level?: string;
notes?: string;
}
export interface CreateHandoffReq {
from_shift_id: string;
to_shift_id: string;
patient_id: string;
notes?: string;
pending_items?: Record<string, unknown>;
}
export interface ListHandoffParams {
page?: number;
page_size?: number;
from_shift_id?: string;
to_shift_id?: string;
}
// --- Constants ---
export const PERIOD_OPTIONS = [
{ label: '上午班', value: 'morning' },
{ label: '下午班', value: 'afternoon' },
{ label: '晚班', value: 'evening' },
{ label: '夜班', value: 'night' },
];
export const SHIFT_STATUS_OPTIONS = [
{ label: '待开始', value: 'scheduled' },
{ label: '进行中', value: 'in_progress' },
{ label: '已完成', value: 'completed' },
{ label: '已取消', value: 'cancelled' },
];
export const CARE_LEVEL_OPTIONS = [
{ label: '稳定', value: 'stable' },
{ label: '需关注', value: 'attention' },
{ label: '危重', value: 'critical' },
];
export const PERIOD_LABEL: Record<string, string> = Object.fromEntries(
PERIOD_OPTIONS.map((o) => [o.value, o.label]),
);
export const SHIFT_STATUS_LABEL: Record<string, string> = Object.fromEntries(
SHIFT_STATUS_OPTIONS.map((o) => [o.value, o.label]),
);
export const SHIFT_STATUS_COLOR: Record<string, string> = {
scheduled: 'default',
in_progress: 'processing',
completed: 'success',
cancelled: 'error',
};
export const CARE_LEVEL_LABEL: Record<string, string> = Object.fromEntries(
CARE_LEVEL_OPTIONS.map((o) => [o.value, o.label]),
);
export const CARE_LEVEL_COLOR: Record<string, string> = {
stable: 'green',
attention: 'orange',
critical: 'red',
};
// --- API ---
export const shiftApi = {
// --- Shifts ---
list: async (params: ListShiftsParams) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Shift>;
}>('/health/shifts', { params });
return data.data;
},
get: async (shiftId: string) => {
const { data } = await client.get<{
success: boolean;
data: Shift;
}>(`/health/shifts/${shiftId}`);
return data.data;
},
create: async (req: CreateShiftReq) => {
const { data } = await client.post<{
success: boolean;
data: Shift;
}>('/health/shifts', req);
return data.data;
},
update: async (shiftId: string, req: UpdateShiftReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: Shift;
}>(`/health/shifts/${shiftId}`, req);
return data.data;
},
delete: async (shiftId: string, version: number) => {
await client.delete(`/health/shifts/${shiftId}`, { data: { version } });
},
// --- Assignments ---
listAssignments: async (shiftId: string, params?: { page?: number; page_size?: number }) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<PatientAssignment>;
}>(`/health/shifts/${shiftId}/assignments`, { params });
return data.data;
},
createAssignment: async (shiftId: string, req: CreatePatientAssignmentReq) => {
const { data } = await client.post<{
success: boolean;
data: PatientAssignment;
}>(`/health/shifts/${shiftId}/assignments`, req);
return data.data;
},
batchAssign: async (shiftId: string, req: BatchAssignReq) => {
const { data } = await client.post<{
success: boolean;
data: PatientAssignment[];
}>(`/health/shifts/${shiftId}/assignments/batch`, req);
return data.data;
},
updateAssignment: async (shiftId: string, assignmentId: string, req: UpdatePatientAssignmentReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: PatientAssignment;
}>(`/health/shifts/${shiftId}/assignments/${assignmentId}`, req);
return data.data;
},
deleteAssignment: async (shiftId: string, assignmentId: string, version: number) => {
await client.delete(`/health/shifts/${shiftId}/assignments/${assignmentId}`, { data: { version } });
},
// --- Handoff Logs ---
listHandoffs: async (params?: ListHandoffParams) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<HandoffLog>;
}>('/health/handoff-logs', { params });
return data.data;
},
createHandoff: async (req: CreateHandoffReq) => {
const { data } = await client.post<{
success: boolean;
data: HandoffLog;
}>('/health/handoff-logs', req);
return data.data;
},
};

View File

@@ -0,0 +1,34 @@
import client from './client';
// --- Types ---
export interface LanguageInfo {
code: string;
name: string;
is_active: boolean;
}
export interface UpdateLanguageRequest {
is_active: boolean;
name?: string;
}
// --- API Functions ---
export async function listLanguages(): Promise<LanguageInfo[]> {
const { data } = await client.get<{ success: boolean; data: LanguageInfo[] }>(
'/config/languages',
);
return data.data;
}
export async function updateLanguage(
code: string,
req: UpdateLanguageRequest,
): Promise<LanguageInfo> {
const { data } = await client.put<{ success: boolean; data: LanguageInfo }>(
`/config/languages/${code}`,
req,
);
return data.data;
}

63
apps/web/src/api/menus.ts Normal file
View File

@@ -0,0 +1,63 @@
import client from './client';
export interface MenuInfo {
id: string;
parent_id?: string;
title: string;
path?: string;
icon?: string;
sort_order: number;
visible: boolean;
menu_type: string;
permission?: string;
children?: MenuInfo[];
version: number;
}
export interface MenuItemReq {
id?: string;
parent_id?: string;
title: string;
path?: string;
icon?: string;
sort_order?: number;
visible?: boolean;
menu_type?: string;
permission?: string;
role_ids?: string[];
version?: number;
}
export async function getMenus() {
const { data } = await client.get<{ success: boolean; data: MenuInfo[] }>('/config/menus');
return data.data;
}
export async function getMenusForUser() {
const { data } = await client.get<{ success: boolean; data: MenuInfo[] }>('/menus/user');
return data.data;
}
export async function batchSaveMenus(menus: MenuItemReq[]) {
await client.put('/config/menus', { menus });
}
export async function createMenu(req: MenuItemReq) {
const { data } = await client.post<{ success: boolean; data: MenuInfo }>(
'/config/menus',
req,
);
return data.data;
}
export async function updateMenu(id: string, req: MenuItemReq) {
const { data } = await client.put<{ success: boolean; data: MenuInfo }>(
`/config/menus/${id}`,
req,
);
return data.data;
}
export async function deleteMenu(id: string, version: number) {
await client.delete(`/config/menus/${id}`, { data: { version } });
}

View File

@@ -0,0 +1,40 @@
import client from './client';
import type { PaginatedResponse } from './types';
export interface MessageTemplateInfo {
id: string;
tenant_id: string;
name: string;
code: string;
channel: string;
title_template: string;
body_template: string;
language: string;
created_at: string;
updated_at: string;
}
export interface CreateTemplateRequest {
name: string;
code: string;
channel?: string;
title_template: string;
body_template: string;
language?: string;
}
export async function listTemplates(page = 1, pageSize = 20) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MessageTemplateInfo> }>(
'/message-templates',
{ params: { page, page_size: pageSize } },
);
return data.data;
}
export async function createTemplate(req: CreateTemplateRequest) {
const { data } = await client.post<{ success: boolean; data: MessageTemplateInfo }>(
'/message-templates',
req,
);
return data.data;
}

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