fix: 前端深度审计全量修复 — 安全/功能/代码质量
严重 BUG 修复: - 修复 Token 过期后 hash 重定向导致无法跳转登录页 - 修复文章编辑器新建后提交审核使用错误 ID 安全加固: - HTML 清理函数替换为 ammonia 专业库(替代自定义解析器) - 文件上传添加 magic bytes 校验(防 Content-Type 伪造) - 登录添加账户级失败锁定(5次失败→15分钟锁定) - 审计日志 9 个关键更新操作补充变更前后值(with_changes) 功能缺陷修复: - 登录/登出时清理 API 缓存(防多账户数据污染) - 文章编辑器上传改用统一 HTTP 客户端(自动 token 刷新) - 添加全局 HTTP 错误处理和后端错误消息展示 - PrivateRoute 增加路由级权限检查(系统管理页面) - 健康数据三个 Tab 添加编辑/删除功能 - 预约创建增加排班可用性校验提示 - 医生详情 API 返回解密后的原始执照号 代码清理: - 删除未使用的 auth.ts refresh() 函数 - 删除重复的 AuthGuard.tsx 组件 - 删除未使用的 getHealthSummary API
This commit is contained in:
358
CLAUDE.md
358
CLAUDE.md
@@ -43,78 +43,50 @@
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目结构
|
||||
## 2. 工作风格
|
||||
|
||||
```text
|
||||
hms/
|
||||
├── crates/ # Rust Workspace
|
||||
│ ├── erp-core/ # L1: 基础类型、错误、事件、模块 trait
|
||||
│ ├── erp-auth/ # L2: 身份与权限模块
|
||||
│ ├── erp-workflow/ # L2: 工作流引擎模块
|
||||
│ ├── erp-message/ # L2: 消息中心模块
|
||||
│ ├── erp-config/ # L2: 系统配置模块
|
||||
│ ├── erp-health/ # L2: 健康管理模块 ★ HMS 核心
|
||||
│ └── erp-server/ # L3: Axum 服务入口,组装所有模块
|
||||
│ └── migration/ # SeaORM 数据库迁移
|
||||
├── apps/
|
||||
│ └── web/ # Vite + React 19 SPA (主力前端)
|
||||
├── packages/
|
||||
│ └── ui-components/ # React 共享组件库
|
||||
├── desktop/ # (可选) Tauri 桌面端,行业需要时启用
|
||||
├── docker/ # Docker 开发环境配置
|
||||
├── docs/ # 文档
|
||||
│ └── superpowers/
|
||||
│ ├── specs/ # 设计规格文档
|
||||
│ └── plans/ # 实施计划
|
||||
├── Cargo.toml # Workspace root
|
||||
└── CLAUDE.md # 本文件 — 协作规则
|
||||
```
|
||||
|
||||
### 2.1 Crate 依赖关系
|
||||
|
||||
```text
|
||||
erp-core (无业务依赖)
|
||||
↑
|
||||
erp-auth (→ core)
|
||||
erp-config (→ core)
|
||||
erp-workflow (→ core)
|
||||
erp-message (→ core)
|
||||
erp-health (→ core) ★ HMS 核心
|
||||
↑
|
||||
erp-server (→ 所有 crate,组装入口)
|
||||
```
|
||||
|
||||
**规则:**
|
||||
- `erp-core` 不依赖任何业务 crate
|
||||
- 业务 crate 之间**禁止**直接依赖,只通过事件总线和 `erp-core` trait 通信
|
||||
- `erp-server` 是唯一的组装点
|
||||
|
||||
### 2.2 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 后端框架 | Axum 0.8 + Tokio |
|
||||
| ORM | SeaORM (异步、类型安全) |
|
||||
| 数据库 | PostgreSQL 18 |
|
||||
| 缓存 | Redis 7+ |
|
||||
| 前端框架 | React 19 + TypeScript 6 (Vite 8) |
|
||||
| UI 组件库 | Ant Design 6 |
|
||||
| 状态管理 | Zustand 5 |
|
||||
| 路由 | React Router 7 |
|
||||
| 样式 | TailwindCSS + CSS Variables |
|
||||
| API 文档 | utoipa (OpenAPI 3) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 工作风格
|
||||
|
||||
### 3.1 按计划推进
|
||||
### 2.1 按计划推进
|
||||
|
||||
- **严格按 Phase 顺序执行** — Phase 2 依赖 Phase 1 的基础设施
|
||||
- **每个 Task 完成后立即提交** — 不积压,保持可追溯
|
||||
- **先测试后实现** — TDD 流程:写失败测试 → 实现 → 通过 → 提交
|
||||
|
||||
### 3.2 模块化思维
|
||||
### 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 模块化思维
|
||||
|
||||
开发任何功能时先问:
|
||||
|
||||
@@ -123,7 +95,7 @@ erp-server (→ 所有 crate,组装入口)
|
||||
3. **它需要发什么事件?** — 跨模块通知必须走事件总线
|
||||
4. **其他模块怎么发现它?** — 通过 `ErpModule` trait 注册
|
||||
|
||||
### 3.3 闭环工作法(强制)
|
||||
### 2.5 闭环工作法(强制)
|
||||
|
||||
每次改动**必须**按顺序完成以下步骤,不允许跳过:
|
||||
|
||||
@@ -144,7 +116,7 @@ erp-server (→ 所有 crate,组装入口)
|
||||
- `cargo test --workspace` — 所有测试通过(有相关测试时)
|
||||
- 功能验证 — 启动后端 + 前端服务,在浏览器中实际操作验证改动生效(涉及 API 或 UI 时)
|
||||
- `pnpm build` — 前端生产构建通过(涉及前端时)
|
||||
5. **提交** — 验证通过后按 §10 规范提交
|
||||
5. **提交** — 验证通过后按 §5 规范提交
|
||||
6. **文档同步** — 更新相关文档(如果涉及架构、接口、模块变化)
|
||||
7. **推送到仓库** — 提交后立即 `git push`,确保远程仓库同步
|
||||
|
||||
@@ -156,23 +128,23 @@ erp-server (→ 所有 crate,组装入口)
|
||||
|
||||
---
|
||||
|
||||
## 4. 实现规则
|
||||
## 3. 实现规则
|
||||
|
||||
### 4.1 错误处理
|
||||
### 3.1 错误处理
|
||||
|
||||
- **跨 crate 边界**:使用 `thiserror` 定义类型化错误,转换为 `AppError`
|
||||
- **crate 内部**:可以使用 `anyhow`,但**永远不**跨越 crate 边界
|
||||
- **数据库错误**:通过 `From<sea_orm::DbErr>` 自动转换为 `AppError`
|
||||
- **验证错误**:包含字段级详情,方便 UI 渲染
|
||||
|
||||
### 4.2 数据库操作
|
||||
### 3.2 数据库操作
|
||||
|
||||
- 所有 SeaORM Entity 必须包含:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
|
||||
- 查询时**始终**带 `tenant_id` 过滤(中间件自动注入)
|
||||
- 更新时检查 `version` 字段实现乐观锁
|
||||
- 删除使用软删除(设置 `deleted_at`)
|
||||
|
||||
### 4.3 API 设计
|
||||
### 3.3 API 设计
|
||||
|
||||
- 所有端点使用 `/api/v1/` 前缀
|
||||
- 响应统一使用 `ApiResponse<T>` 包装
|
||||
@@ -180,14 +152,14 @@ erp-server (→ 所有 crate,组装入口)
|
||||
- utoipa 自动生成 OpenAPI 文档
|
||||
- 租户 ID 从 JWT 中间件注入,**不在** API 路径中传递(管理员接口除外)
|
||||
|
||||
### 4.4 事件总线
|
||||
### 3.4 事件总线
|
||||
|
||||
- 模块间通信**只能**通过 `EventBus`
|
||||
- 事件必须持久化到 `domain_events` 表(outbox 模式)
|
||||
- 事件处理失败记录到 dead-letter 存储
|
||||
- 事件类型命名:`{模块}.{动作}` 如 `user.created`, `workflow.task.completed`
|
||||
|
||||
### 4.5 Rust 代码规范
|
||||
### 3.5 Rust 代码规范
|
||||
|
||||
```rust
|
||||
// 命名:snake_case (函数/变量), PascalCase (类型/trait), SCREAMING_SNAKE (常量)
|
||||
@@ -197,7 +169,7 @@ erp-server (→ 所有 crate,组装入口)
|
||||
// 数据库操作使用 SeaORM 的 Entity + Model + Relation 模式
|
||||
```
|
||||
|
||||
### 4.6 TypeScript / React 代码规范
|
||||
### 3.6 TypeScript / React 代码规范
|
||||
|
||||
```typescript
|
||||
// 避免 any,优先 unknown + 类型守卫
|
||||
@@ -210,58 +182,9 @@ erp-server (→ 所有 crate,组装入口)
|
||||
|
||||
---
|
||||
|
||||
## 5. 模块开发规范
|
||||
## 4. 测试与验证
|
||||
|
||||
### 5.1 新建业务模块清单
|
||||
|
||||
每个新模块(如 erp-auth, erp-workflow)**必须**包含:
|
||||
|
||||
1. `Cargo.toml` — 依赖 `erp-core`
|
||||
2. `src/lib.rs` — 模块入口,实现 `ErpModule` trait
|
||||
3. `src/error.rs` — 模块错误类型,wrap `AppError`
|
||||
4. `src/entity/` — SeaORM Entity 定义
|
||||
5. `src/service/` — 业务逻辑层
|
||||
6. `src/handler/` — Axum 路由处理器
|
||||
7. `src/event.rs` — 模块事件定义和处理器
|
||||
|
||||
### 5.2 ErpModule trait 实现
|
||||
|
||||
```rust
|
||||
pub struct AuthModule;
|
||||
|
||||
impl ErpModule for AuthModule {
|
||||
fn name(&self) -> &str { "auth" }
|
||||
fn version(&self) -> &str { env!("CARGO_PKG_VERSION") }
|
||||
fn dependencies(&self) -> Vec<&str> { vec![] } // auth 是基础模块,无依赖
|
||||
|
||||
fn register_routes(&self, router: Router) -> Router {
|
||||
router.nest("/api/v1", auth_routes())
|
||||
}
|
||||
|
||||
fn register_event_handlers(&self, bus: &EventBus) {
|
||||
// 订阅其他模块的事件
|
||||
}
|
||||
|
||||
async fn on_tenant_created(&self, tenant_id: Uuid) -> AppResult<()> {
|
||||
// 初始化默认角色、管理员等
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 数据库迁移
|
||||
|
||||
- 迁移文件放在 `crates/erp-server/migration/src/`
|
||||
- 命名格式:`m{YYYYMMDD}_{6位序号}_{描述}.rs`
|
||||
- 迁移必须可回滚(实现 `down` 方法)
|
||||
- 新增表必须包含所有标准字段(§1.3)
|
||||
- 迁移必须幂等(使用 `if_not_exists`)
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试与验证
|
||||
|
||||
### 6.1 测试要求
|
||||
### 4.1 测试要求
|
||||
|
||||
| 测试类型 | 覆盖目标 | 工具 |
|
||||
|----------|---------|------|
|
||||
@@ -271,7 +194,7 @@ impl ErpModule for AuthModule {
|
||||
| E2E 测试 | 前端关键流程 | Playwright |
|
||||
| 插件测试 | 动态表 CRUD + 租户隔离 | Testcontainers |
|
||||
|
||||
### 6.2 验证命令
|
||||
### 4.2 验证命令
|
||||
|
||||
```bash
|
||||
# Rust 编译检查
|
||||
@@ -293,7 +216,7 @@ cd apps/desktop && pnpm tauri dev
|
||||
docker exec erp-postgres psql -U erp -c "\dt"
|
||||
```
|
||||
|
||||
### 6.3 Phase 完成标准
|
||||
### 4.3 Phase 完成标准
|
||||
|
||||
每个 Phase 完成时必须满足:
|
||||
|
||||
@@ -307,102 +230,7 @@ docker exec erp-postgres psql -U erp -c "\dt"
|
||||
|
||||
---
|
||||
|
||||
## 7. 安全注意事项
|
||||
|
||||
### 7.1 认证安全
|
||||
|
||||
- **密码存储**: Argon2 哈希,禁止明文
|
||||
- **JWT**: access token 15min + refresh token 7d
|
||||
- **Refresh Token 轮换**: 每次使用后签发新的,旧的作废
|
||||
- **Token 存储**: 桌面端使用 Tauri secure store
|
||||
- **密码修改**: 使所有已签发的 JWT 失效
|
||||
|
||||
### 7.2 多租户安全
|
||||
|
||||
- **中间件注入**: `tenant_id` 从 JWT 中提取,应用层不可伪造
|
||||
- **数据隔离**: 所有查询自动过滤 `tenant_id`
|
||||
- **越权防护**: 禁止跨租户数据访问
|
||||
- **租户 provisioning**: `on_tenant_created` 钩子初始化数据
|
||||
|
||||
### 7.3 通用安全
|
||||
|
||||
- **不硬编码密钥** — 使用环境变量或配置文件
|
||||
- **用户输入验证** — 所有 API 端点验证输入
|
||||
- **SQL 注入防护** — SeaORM 参数化查询
|
||||
- **限流** — Redis token bucket,登录等敏感接口限流
|
||||
- **CORS** — 白名单制,默认拒绝
|
||||
- **审计日志** — 所有关键操作记录变更前后状态
|
||||
|
||||
---
|
||||
|
||||
## 8. 桌面端 UI 规范
|
||||
|
||||
### 8.1 布局结构
|
||||
|
||||
经典 SaaS 后台管理布局(响应式,支持移动端):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ LOGO 搜索... 🔔 5 👤 Admin ▾ │ ← 顶部导航栏
|
||||
├─────────┬───────────────────────────────────┤
|
||||
│ 📊 首页 │ │
|
||||
│ 👥 用户 │ 主内容区域 │
|
||||
│ 🔐 权限 │ (多标签页切换) │
|
||||
│ 📋 流程 │ │
|
||||
│ 💬 消息 │ │
|
||||
│ ⚙️ 设置 │ │
|
||||
│─────────│ │
|
||||
│ 📦 进销存│ │
|
||||
│ 🏭 生产 │ │
|
||||
│ 💰 财务 │ │
|
||||
│─────────│ │
|
||||
│ ▸ 更多 │ │
|
||||
└─────────┴───────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 UI 规则
|
||||
|
||||
- 使用 Ant Design 组件库,不自造轮子
|
||||
- 中文优先,所有文案通过 i18n key 引用
|
||||
- 支持暗色/亮色主题切换
|
||||
- 侧边栏按模块分组:基础模块 / 行业模块
|
||||
- 表单验证使用 Ant Design Form 的 validateRules
|
||||
|
||||
---
|
||||
|
||||
## 9. 常用命令
|
||||
|
||||
```bash
|
||||
# === Rust ===
|
||||
cargo check # 编译检查
|
||||
cargo test --workspace # 运行所有测试
|
||||
cargo run -p erp-server # 启动后端服务(需从 crates/erp-server 目录 + 环境变量)
|
||||
cargo fmt --check # 检查格式
|
||||
cargo clippy -- -D warnings # Lint 检查
|
||||
|
||||
# === 前端 ===
|
||||
cd apps/web && pnpm install # 安装依赖
|
||||
cd apps/web && pnpm dev # 开发模式(固定端口 5174)
|
||||
cd apps/web && pnpm build # 构建生产版本
|
||||
|
||||
# === 数据库(Windows 原生 PostgreSQL) ===
|
||||
PGPASSWORD=123123 "D:\postgreSQL\bin\psql.exe" -U postgres -h localhost -d erp # 连接数据库
|
||||
|
||||
# === WASM 插件 ===
|
||||
cargo build -p erp-plugin-test-sample --target wasm32-unknown-unknown --release # 编译测试插件
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_test_sample.wasm -o target/erp_plugin_test_sample.component.wasm # 转为 Component
|
||||
cargo test -p erp-plugin-prototype # 运行插件集成测试
|
||||
|
||||
# === 一键启动 (PowerShell) ===
|
||||
.\dev.ps1 # 启动前后端(自动清理端口占用)
|
||||
.\dev.ps1 -Stop # 停止前后端
|
||||
.\dev.ps1 -Restart # 重启前后端
|
||||
.\dev.ps1 -Status # 查看端口状态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 提交规范
|
||||
## 5. 提交规范
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
@@ -447,68 +275,7 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
|
||||
---
|
||||
|
||||
## 11. 设计文档索引
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| `docs/superpowers/specs/2026-04-23-health-management-module-design.md` | **HMS 健康模块设计规格** ★ 当前 |
|
||||
| `docs/superpowers/specs/2026-04-10-erp-platform-base-design.md` | 平台底座设计规格 |
|
||||
| `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | WASM 插件系统设计规格 |
|
||||
| `docs/superpowers/specs/2026-04-16-crm-plugin-design.md` | CRM 客户管理插件设计规格 |
|
||||
|
||||
所有设计决策以设计规格文档为准。实施计划按阶段拆分,每阶段开始前细化。
|
||||
|
||||
---
|
||||
|
||||
<!-- ARCH-SNAPSHOT-START -->
|
||||
<!-- 此区域随开发进度更新 -->
|
||||
|
||||
## 12. 当前架构快照
|
||||
|
||||
### 开发进度
|
||||
|
||||
| Phase | 内容 | 状态 |
|
||||
|-------|------|------|
|
||||
| Phase 1 | 基础设施 (workspace + core + Docker + 桌面端) | ✅ 完成 |
|
||||
| Phase 2 | 身份与权限 (Auth) | ✅ 完成 |
|
||||
| Phase 3 | 系统配置 (Config) | ✅ 完成 |
|
||||
| Phase 4 | 工作流引擎 (Workflow) | ✅ 完成 |
|
||||
| Phase 5 | 消息中心 (Message) | ✅ 完成 |
|
||||
| Phase 6 | 整合与打磨 | ✅ 完成 |
|
||||
| - | WASM 插件原型 (V1-V6) | ✅ 验证通过 |
|
||||
| - | 插件系统集成到主服务 | ✅ 已集成 |
|
||||
| - | CRM 插件 (Phase 1-3) | ✅ 完成 |
|
||||
| - | Q2 安全地基 + CI/CD | ✅ 完成 |
|
||||
| - | Q3 架构强化 + 前端体验 | ✅ 完成 |
|
||||
| - | Q4 测试覆盖 + 插件生态 | ✅ 完成 |
|
||||
| - | 健康模块迭代 (安全地基 + 后端补完 + Web 前端 10 页面) | ✅ 完成 |
|
||||
|
||||
### 已实现模块
|
||||
|
||||
| Crate | 功能 | 状态 |
|
||||
|-------|------|------|
|
||||
| erp-core | 错误类型、共享类型、事件总线、ErpModule trait、审计日志 | ✅ 完成 |
|
||||
| erp-server | Axum 服务入口、配置、数据库连接、CORS、模块注册、后台任务 | ✅ 完成 |
|
||||
| erp-auth | 身份与权限 (用户/角色/权限/组织/部门/岗位/行级数据权限) | ✅ 完成 |
|
||||
| erp-workflow | 工作流引擎 (BPMN 解析/Token 驱动/任务分配) | ✅ 完成 |
|
||||
| erp-message | 消息中心 (CRUD/模板/订阅/通知面板) | ✅ 完成 |
|
||||
| erp-config | 系统配置 (字典/菜单/设置/编号规则/主题) | ✅ 完成 |
|
||||
| erp-plugin | 插件管理 (WASM 运行时/生命周期/动态表/数据CRUD/热更新/行级数据权限) | ✅ 已集成 |
|
||||
| erp-plugin-prototype | WASM 插件 Host 运行时 (Wasmtime + bindgen + Host API) | ✅ 原型验证 |
|
||||
| erp-plugin-test-sample | WASM 测试插件 (Guest trait + Host API 回调) | ✅ 原型验证 |
|
||||
| erp-plugin-crm | CRM 客户管理插件 (5 实体/9 权限/6 页面) | ✅ 完成 |
|
||||
| erp-plugin-inventory | 进销存管理插件 (6 实体/12 权限/6 页面) | ✅ 完成 |
|
||||
| erp-plugin-freelance | 自由职业者管理插件 | ✅ 完成 |
|
||||
| erp-plugin-itops | IT 运维管理插件 | ✅ 完成 |
|
||||
| erp-health | 健康管理原生模块 (16 实体/12 权限/13 页面) | ✅ 完成 |
|
||||
|
||||
<!-- ARCH-SNAPSHOT-END -->
|
||||
|
||||
---
|
||||
|
||||
<!-- ANTI-PATTERN-START -->
|
||||
|
||||
## 13. 反模式警告
|
||||
## 6. 反模式警告
|
||||
|
||||
- ❌ **不要**不看 wiki 就开干 — wiki 包含环境配置、数据库连接、启动方式、已知问题,不看就做等于盲猜,浪费时间且产出不可信
|
||||
- ❌ **不要**在业务 crate 之间创建直接依赖 — 只通过事件和 trait 通信
|
||||
@@ -527,6 +294,8 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
- ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过
|
||||
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步
|
||||
- ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档
|
||||
- ❌ **不要**一次性输出长文档 — 超过 200 行的文档必须分步编写(先大纲 → 逐章 → 整合),否则会因上下文过长卡死
|
||||
- ❌ **不要**忽略讨论记录 — 每次发散式讨论结束后必须建立文档到 `docs/discussions/`,不要口头确认后就忘
|
||||
|
||||
### 场景化指令
|
||||
|
||||
@@ -538,4 +307,19 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
- 当遇到**新增页面** → 使用 Ant Design 组件,i18n key 引用文案
|
||||
- 当遇到**新增业务模块插件** → 参考 `wiki/wasm-plugin.md` 的插件制作完整流程和 `.claude/skills/plugin-development/SKILL.md`,创建 cdylib crate + 实现 Guest trait + 编译为 WASM Component。**权限码必须与实体名一致(每个实体声明 `.list` + `.manage`)**
|
||||
|
||||
<!-- ANTI-PATTERN-END -->
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
231
Cargo.lock
generated
231
Cargo.lock
generated
@@ -102,6 +102,19 @@ version = "0.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
|
||||
|
||||
[[package]]
|
||||
name = "ammonia"
|
||||
version = "4.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
|
||||
dependencies = [
|
||||
"cssparser",
|
||||
"html5ever",
|
||||
"maplit",
|
||||
"tendril",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -1047,6 +1060,29 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
|
||||
dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser-macros"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csv"
|
||||
version = "1.4.0"
|
||||
@@ -1279,6 +1315,21 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa-short"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
|
||||
dependencies = [
|
||||
"dtoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -1392,6 +1443,7 @@ name = "erp-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -1743,6 +1795,16 @@ version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
||||
dependencies = [
|
||||
"mac",
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
@@ -2077,6 +2139,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever",
|
||||
"match_token",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -2610,6 +2683,12 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mac_address"
|
||||
version = "1.1.8"
|
||||
@@ -2641,6 +2720,34 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maplit"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril",
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "match_token"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -2790,6 +2897,12 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
@@ -3149,6 +3262,58 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -3242,6 +3407,12 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@@ -4201,6 +4372,12 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||
|
||||
[[package]]
|
||||
name = "sized-chunks"
|
||||
version = "0.6.5"
|
||||
@@ -4486,6 +4663,31 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
@@ -4625,6 +4827,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
||||
dependencies = [
|
||||
"futf",
|
||||
"mac",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
@@ -5156,6 +5369,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -5825,6 +6044,18 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
|
||||
dependencies = [
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
|
||||
@@ -110,3 +110,6 @@ async-stream = "0.3"
|
||||
|
||||
# Template engine
|
||||
handlebars = "6"
|
||||
|
||||
# HTML sanitization
|
||||
ammonia = "4"
|
||||
|
||||
@@ -52,7 +52,18 @@ const ArticleTagManage = lazy(() => import('./pages/health/ArticleTagManage'));
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
const permissions = useAuthStore((s) => s.permissions);
|
||||
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
|
||||
// 路由级权限检查:如果用户对某个模块完全没有权限,重定向到首页
|
||||
const path = window.location.hash.replace('#', '');
|
||||
if (path.startsWith('/users') || path.startsWith('/roles') || path.startsWith('/organizations')) {
|
||||
const hasAuthAccess = permissions.some((p) => p.startsWith('auth.'));
|
||||
if (!hasAuthAccess) return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const themeConfig = {
|
||||
|
||||
@@ -40,14 +40,6 @@ export async function login(req: LoginRequest): Promise<LoginResponse> {
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function refresh(refreshToken: string): Promise<LoginResponse> {
|
||||
const { data } = await client.post<{ success: boolean; data: LoginResponse }>(
|
||||
'/auth/refresh',
|
||||
{ refresh_token: refreshToken }
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await client.post('/auth/logout');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { message as antMessage } from 'antd';
|
||||
|
||||
// 请求缓存:短时间内相同请求复用结果
|
||||
interface CacheEntry {
|
||||
@@ -138,7 +139,7 @@ client.interceptors.response.use(
|
||||
processQueue(refreshError, null);
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.hash = '#/login';
|
||||
window.location.hash = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
@@ -149,6 +150,32 @@ client.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
// 全局错误提示(仅对未被组件处理的错误显示)
|
||||
let globalErrorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function showGlobalError(msg: string) {
|
||||
// 防止短时间内弹出大量相同提示
|
||||
if (globalErrorTimer) return;
|
||||
antMessage.error(msg, 3);
|
||||
globalErrorTimer = setTimeout(() => { globalErrorTimer = null; }, 3000);
|
||||
}
|
||||
|
||||
// 全局错误拦截 — 在响应拦截器之后、组件 catch 之前执行
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (!error.response) {
|
||||
showGlobalError('网络连接异常,请检查网络');
|
||||
} else if (error.response.status === 403) {
|
||||
showGlobalError('权限不足,无法执行此操作');
|
||||
} 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;
|
||||
@@ -168,4 +195,12 @@ 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;
|
||||
|
||||
@@ -131,6 +131,7 @@ export const patientApi = {
|
||||
await client.post(`/health/patients/${id}/tags`, { tag_ids: tagIds });
|
||||
},
|
||||
|
||||
// TODO: 未使用,待未来健康摘要功能接入时启用
|
||||
getHealthSummary: async (id: string) => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
|
||||
interface AuthGuardProps {
|
||||
code: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthGuard({ code, children }: AuthGuardProps) {
|
||||
const { hasPermission } = usePermission(code);
|
||||
if (!hasPermission) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
message,
|
||||
Card,
|
||||
Row,
|
||||
Alert,
|
||||
Col,
|
||||
} from 'antd';
|
||||
import {
|
||||
@@ -88,6 +89,10 @@ export default function AppointmentList() {
|
||||
const [selectedDoctorId, setSelectedDoctorId] = useState<string | undefined>(undefined);
|
||||
const [nameCache, setNameCache] = useState<Record<string, string>>({});
|
||||
|
||||
// 排班校验
|
||||
const [scheduleHint, setScheduleHint] = useState<string | null>(null);
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
|
||||
// ---- 数据获取 ----
|
||||
const fetchData = useCallback(async (p = page, ps = pageSize) => {
|
||||
setLoading(true);
|
||||
@@ -194,9 +199,36 @@ export default function AppointmentList() {
|
||||
form.resetFields();
|
||||
setSelectedPatientId(undefined);
|
||||
setSelectedDoctorId(undefined);
|
||||
setScheduleHint(null);
|
||||
setSelectedDate(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// 排班校验:医生 + 日期选定后查询排班
|
||||
useEffect(() => {
|
||||
if (!selectedDoctorId || !selectedDate || !modalOpen) {
|
||||
setScheduleHint(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
appointmentApi.listSchedules({ doctor_id: selectedDoctorId, date: selectedDate, page: 1, page_size: 50 })
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
const schedules = result.data;
|
||||
if (schedules.length === 0) {
|
||||
setScheduleHint(`该医生在 ${selectedDate} 暂无排班,请确认是否需要先创建排班`);
|
||||
} else {
|
||||
const slots = schedules
|
||||
.filter((s) => s.status === 'active' && s.current_appointments < s.max_appointments)
|
||||
.map((s) => `${s.start_time}-${s.end_time}(${s.current_appointments}/${s.max_appointments})`)
|
||||
.join('、');
|
||||
setScheduleHint(slots ? `可约时段:${slots}` : `该医生在 ${selectedDate} 排班已满或已停用`);
|
||||
}
|
||||
})
|
||||
.catch(() => { if (!cancelled) setScheduleHint(null); });
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedDoctorId, selectedDate, modalOpen]);
|
||||
|
||||
const handleSubmit = async (values: {
|
||||
appointment_date: Dayjs;
|
||||
start_time: Dayjs;
|
||||
@@ -388,11 +420,20 @@ export default function AppointmentList() {
|
||||
form.resetFields();
|
||||
setSelectedPatientId(undefined);
|
||||
setSelectedDoctorId(undefined);
|
||||
setScheduleHint(null);
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
>
|
||||
{scheduleHint && (
|
||||
<Alert
|
||||
message={scheduleHint}
|
||||
type={scheduleHint.includes('暂无排班') ? 'warning' : 'info'}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item label="患者" required>
|
||||
<PatientSelect
|
||||
@@ -415,7 +456,7 @@ export default function AppointmentList() {
|
||||
label="预约日期"
|
||||
rules={[{ required: true, message: '请选择预约日期' }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
<DatePicker style={{ width: '100%' }} onChange={(d) => setSelectedDate(d ? d.format('YYYY-MM-DD') : null)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../api/health/articles';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import client from '../../api/client';
|
||||
import '@wangeditor/editor/dist/css/style.css';
|
||||
|
||||
export default function ArticleEditor() {
|
||||
@@ -108,15 +109,11 @@ export default function ArticleEditor() {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const token = localStorage.getItem('access_token');
|
||||
const resp = await fetch('/api/v1/upload', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
const { data: result } = await client.post('/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
if (!resp.ok) throw new Error('上传失败');
|
||||
const result = await resp.json();
|
||||
const url: string = result.data.url;
|
||||
const token = localStorage.getItem('access_token');
|
||||
const urlWithToken = token ? `${url}?token=${token}` : url;
|
||||
insertFn(urlWithToken, file.name, urlWithToken);
|
||||
} catch {
|
||||
@@ -226,12 +223,15 @@ export default function ArticleEditor() {
|
||||
});
|
||||
currentVersion = created.version;
|
||||
setVersion(created.version);
|
||||
navigate(`/health/articles/${created.id}/edit`, { replace: true });
|
||||
// 新建后直接提交审核(此时 id 仍为 undefined,使用 created.id)
|
||||
await articleApi.submit(created.id, currentVersion);
|
||||
message.success('已提交审核');
|
||||
navigate('/health/articles');
|
||||
return;
|
||||
}
|
||||
// 提交审核
|
||||
if (id || isEdit) {
|
||||
const articleId = id!;
|
||||
await articleApi.submit(articleId, currentVersion);
|
||||
// 编辑模式提交审核
|
||||
if (id) {
|
||||
await articleApi.submit(id, currentVersion);
|
||||
}
|
||||
message.success('已提交审核');
|
||||
navigate('/health/articles');
|
||||
@@ -471,14 +471,9 @@ export default function ArticleEditor() {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const token = localStorage.getItem('access_token');
|
||||
const resp = await fetch('/api/v1/upload', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
const { data: result } = await client.post('/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
if (!resp.ok) throw new Error('上传失败');
|
||||
const result = await resp.json();
|
||||
setCoverImage(result.data.url);
|
||||
message.success('封面图上传成功');
|
||||
} catch {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Table, Tag, Button, Modal, Form, Select, DatePicker, Input, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Table, Tag, Button, Modal, Form, Select, DatePicker, Input, message, Popconfirm, Space } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { healthDataApi } from '../../../api/health/healthData';
|
||||
import type { HealthRecord } from '../../../api/health/healthData';
|
||||
import { AuthButton } from '../../../components/AuthButton';
|
||||
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
||||
|
||||
interface Props {
|
||||
@@ -22,15 +24,9 @@ const RECORD_TYPE_MAP: Record<string, string> = {
|
||||
inpatient: '住院',
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '记录类型', dataIndex: 'record_type', key: 'record_type', width: 120, render: (v: string) => <Tag>{RECORD_TYPE_MAP[v] || v}</Tag> },
|
||||
{ title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 120 },
|
||||
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
|
||||
];
|
||||
|
||||
export function HealthRecordsTab({ patientId }: Props) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<HealthRecord | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -43,33 +39,112 @@ export function HealthRecordsTab({ patientId }: Props) {
|
||||
|
||||
const { data, total, page, loading, refresh } = usePaginatedData<HealthRecord>(fetcher, 10);
|
||||
|
||||
const handleCreate = async (values: {
|
||||
const isEditing = editingRecord !== null;
|
||||
const modalTitle = isEditing ? '编辑健康记录' : '添加健康记录';
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (record: HealthRecord) => {
|
||||
setEditingRecord(record);
|
||||
form.setFieldsValue({
|
||||
record_type: record.record_type,
|
||||
record_date: dayjs(record.record_date),
|
||||
content: record.content,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: {
|
||||
record_type: 'checkup' | 'outpatient' | 'inpatient';
|
||||
record_date: Dayjs;
|
||||
content?: string;
|
||||
}) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await healthDataApi.createHealthRecord(patientId, {
|
||||
record_type: values.record_type,
|
||||
record_date: values.record_date.format('YYYY-MM-DD'),
|
||||
content: values.content,
|
||||
});
|
||||
message.success('健康记录添加成功');
|
||||
if (isEditing && editingRecord) {
|
||||
await healthDataApi.updateHealthRecord(patientId, editingRecord.id, {
|
||||
record_type: values.record_type,
|
||||
record_date: values.record_date.format('YYYY-MM-DD'),
|
||||
content: values.content,
|
||||
version: editingRecord.version,
|
||||
});
|
||||
message.success('健康记录更新成功');
|
||||
} else {
|
||||
await healthDataApi.createHealthRecord(patientId, {
|
||||
record_type: values.record_type,
|
||||
record_date: values.record_date.format('YYYY-MM-DD'),
|
||||
content: values.content,
|
||||
});
|
||||
message.success('健康记录添加成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
setEditingRecord(null);
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('添加失败');
|
||||
message.error(isEditing ? '更新失败' : '添加失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (record: HealthRecord) => {
|
||||
try {
|
||||
await healthDataApi.deleteHealthRecord(patientId, record.id);
|
||||
message.success('健康记录删除成功');
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '记录类型', dataIndex: 'record_type', key: 'record_type', width: 120, render: (v: string) => <Tag>{RECORD_TYPE_MAP[v] || v}</Tag> },
|
||||
{ title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 120 },
|
||||
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
render: (_: unknown, record: HealthRecord) => (
|
||||
<Space size="small">
|
||||
<AuthButton code="health.health-data.manage">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditModal(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<AuthButton code="health.health-data.manage">
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="确定要删除这条健康记录吗?"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||
添加记录
|
||||
</Button>
|
||||
</div>
|
||||
@@ -87,15 +162,15 @@ export function HealthRecordsTab({ patientId }: Props) {
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="添加健康记录"
|
||||
title={modalTitle}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onCancel={() => { setModalOpen(false); setEditingRecord(null); }}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
width={520}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="record_type" label="记录类型" rules={[{ required: true, message: '请选择类型' }]}>
|
||||
<Select placeholder="请选择记录类型" options={RECORD_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message, Popconfirm, Space } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { healthDataApi } from '../../../api/health/healthData';
|
||||
import type { LabReport } from '../../../api/health/healthData';
|
||||
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
||||
import { AuthButton } from '../../../components/AuthButton';
|
||||
import { handleApiError } from '../../../api/client';
|
||||
|
||||
interface Props {
|
||||
patientId: string;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '报告日期', dataIndex: 'report_date', key: 'report_date', width: 120 },
|
||||
{ title: '报告类型', dataIndex: 'report_type', key: 'report_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
|
||||
{ title: '医生解读', dataIndex: 'doctor_interpretation', key: 'doctor_interpretation', ellipsis: true },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
|
||||
];
|
||||
|
||||
export function LabReportsTab({ patientId }: Props) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<LabReport | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -31,33 +28,96 @@ export function LabReportsTab({ patientId }: Props) {
|
||||
|
||||
const { data, total, page, loading, refresh } = usePaginatedData<LabReport>(fetcher, 10);
|
||||
|
||||
const handleCreate = async (values: {
|
||||
const openCreateModal = () => {
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (record: LabReport) => {
|
||||
setEditingRecord(record);
|
||||
form.setFieldsValue({
|
||||
report_date: dayjs(record.report_date),
|
||||
report_type: record.report_type,
|
||||
doctor_interpretation: record.doctor_interpretation,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: {
|
||||
report_date: Dayjs;
|
||||
report_type: string;
|
||||
doctor_interpretation?: string;
|
||||
}) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await healthDataApi.createLabReport(patientId, {
|
||||
report_date: values.report_date.format('YYYY-MM-DD'),
|
||||
report_type: values.report_type,
|
||||
doctor_interpretation: values.doctor_interpretation,
|
||||
});
|
||||
message.success('化验报告添加成功');
|
||||
if (editingRecord) {
|
||||
await healthDataApi.updateLabReport(patientId, editingRecord.id, {
|
||||
report_date: values.report_date.format('YYYY-MM-DD'),
|
||||
report_type: values.report_type,
|
||||
doctor_interpretation: values.doctor_interpretation,
|
||||
version: editingRecord.version,
|
||||
});
|
||||
message.success('化验报告更新成功');
|
||||
} else {
|
||||
await healthDataApi.createLabReport(patientId, {
|
||||
report_date: values.report_date.format('YYYY-MM-DD'),
|
||||
report_type: values.report_type,
|
||||
doctor_interpretation: values.doctor_interpretation,
|
||||
});
|
||||
message.success('化验报告添加成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('添加失败');
|
||||
} catch (err) {
|
||||
handleApiError(err, editingRecord ? '更新失败' : '添加失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (record: LabReport) => {
|
||||
try {
|
||||
await healthDataApi.deleteLabReport(patientId, record.id);
|
||||
message.success('化验报告删除成功');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
handleApiError(err, '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '报告日期', dataIndex: 'report_date', key: 'report_date', width: 120 },
|
||||
{ title: '报告类型', dataIndex: 'report_type', key: 'report_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
|
||||
{ title: '医生解读', dataIndex: 'doctor_interpretation', key: 'doctor_interpretation', ellipsis: true },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (_: unknown, record: LabReport) => (
|
||||
<AuthButton code="health.health-data.manage">
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确认删除该化验报告?" onConfirm={() => handleDelete(record)} okText="确认" cancelText="取消">
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</AuthButton>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||
添加报告
|
||||
</Button>
|
||||
</div>
|
||||
@@ -75,15 +135,15 @@ export function LabReportsTab({ patientId }: Props) {
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="添加化验报告"
|
||||
title={editingRecord ? '编辑化验报告' : '添加化验报告'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onCancel={() => { setModalOpen(false); setEditingRecord(null); }}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
width={520}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="report_date" label="报告日期" rules={[{ required: true, message: '请选择日期' }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Table, Button, Modal, Form, InputNumber, DatePicker, Input, message, Typography, Tooltip } from 'antd';
|
||||
import { PlusOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { useCallback, useState, useMemo } from 'react';
|
||||
import { Table, Button, Modal, Form, InputNumber, DatePicker, Input, message, Typography, Tooltip, Popconfirm, Space } from 'antd';
|
||||
import { PlusOutlined, InfoCircleOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import { healthDataApi } from '../../../api/health/healthData';
|
||||
import type { VitalSigns } from '../../../api/health/healthData';
|
||||
import { VitalSignsChart } from './VitalSignsChart';
|
||||
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
||||
import { AuthButton } from '../../../components/AuthButton';
|
||||
import { handleApiError } from '../../../api/client';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -13,61 +16,9 @@ interface Props {
|
||||
patientId: string;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '记录日期',
|
||||
dataIndex: 'record_date',
|
||||
key: 'record_date',
|
||||
width: 110,
|
||||
fixed: 'left' as const,
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<Tooltip title="晨间收缩压">
|
||||
<span>收缩压(晨)</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'systolic_bp_morning',
|
||||
key: 'systolic_bp_morning',
|
||||
width: 110,
|
||||
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<Tooltip title="晨间舒张压">
|
||||
<span>舒张压(晨)</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'diastolic_bp_morning',
|
||||
key: 'diastolic_bp_morning',
|
||||
width: 110,
|
||||
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
|
||||
},
|
||||
{
|
||||
title: '心率',
|
||||
dataIndex: 'heart_rate',
|
||||
key: 'heart_rate',
|
||||
width: 80,
|
||||
render: (v?: number) => (v != null ? `${v} bpm` : '-'),
|
||||
},
|
||||
{
|
||||
title: '体重',
|
||||
dataIndex: 'weight',
|
||||
key: 'weight',
|
||||
width: 80,
|
||||
render: (v?: number) => (v != null ? `${v} kg` : '-'),
|
||||
},
|
||||
{
|
||||
title: '血糖',
|
||||
dataIndex: 'blood_sugar',
|
||||
key: 'blood_sugar',
|
||||
width: 90,
|
||||
render: (v?: number) => (v != null ? `${v} mmol/L` : '-'),
|
||||
},
|
||||
];
|
||||
|
||||
export function VitalSignsTab({ patientId }: Props) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<VitalSigns | null>(null);
|
||||
const [chartRefreshKey, setChartRefreshKey] = useState(0);
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -81,7 +32,40 @@ export function VitalSignsTab({ patientId }: Props) {
|
||||
|
||||
const { data, total, page, loading, refresh } = usePaginatedData<VitalSigns>(fetcher, 10);
|
||||
|
||||
const handleCreate = async (values: {
|
||||
const handleOpenCreate = () => {
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (record: VitalSigns) => {
|
||||
setEditingRecord(record);
|
||||
form.setFieldsValue({
|
||||
record_date: dayjs(record.record_date),
|
||||
systolic_bp_morning: record.systolic_bp_morning,
|
||||
diastolic_bp_morning: record.diastolic_bp_morning,
|
||||
heart_rate: record.heart_rate,
|
||||
weight: record.weight,
|
||||
blood_sugar: record.blood_sugar,
|
||||
water_intake_ml: record.water_intake_ml,
|
||||
urine_output_ml: record.urine_output_ml,
|
||||
notes: record.notes,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (record: VitalSigns) => {
|
||||
try {
|
||||
await healthDataApi.deleteVitalSigns(patientId, record.id);
|
||||
message.success('删除成功');
|
||||
refresh();
|
||||
setChartRefreshKey((k) => k + 1);
|
||||
} catch (err) {
|
||||
handleApiError(err, '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: {
|
||||
record_date: Dayjs;
|
||||
systolic_bp_morning?: number;
|
||||
diastolic_bp_morning?: number;
|
||||
@@ -94,7 +78,7 @@ export function VitalSignsTab({ patientId }: Props) {
|
||||
}) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await healthDataApi.createVitalSigns(patientId, {
|
||||
const payload = {
|
||||
record_date: values.record_date.format('YYYY-MM-DD'),
|
||||
systolic_bp_morning: values.systolic_bp_morning,
|
||||
diastolic_bp_morning: values.diastolic_bp_morning,
|
||||
@@ -104,18 +88,122 @@ export function VitalSignsTab({ patientId }: Props) {
|
||||
water_intake_ml: values.water_intake_ml,
|
||||
urine_output_ml: values.urine_output_ml,
|
||||
notes: values.notes,
|
||||
});
|
||||
message.success('体征数据录入成功');
|
||||
};
|
||||
|
||||
if (editingRecord) {
|
||||
await healthDataApi.updateVitalSigns(patientId, editingRecord.id, {
|
||||
...payload,
|
||||
version: editingRecord.version,
|
||||
});
|
||||
message.success('体征数据更新成功');
|
||||
} else {
|
||||
await healthDataApi.createVitalSigns(patientId, payload);
|
||||
message.success('体征数据录入成功');
|
||||
}
|
||||
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
setEditingRecord(null);
|
||||
refresh();
|
||||
setChartRefreshKey((k) => k + 1); } catch {
|
||||
message.error('录入失败');
|
||||
setChartRefreshKey((k) => k + 1);
|
||||
} catch (err) {
|
||||
handleApiError(err, editingRecord ? '更新失败' : '录入失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '记录日期',
|
||||
dataIndex: 'record_date',
|
||||
key: 'record_date',
|
||||
width: 110,
|
||||
fixed: 'left' as const,
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<Tooltip title="晨间收缩压">
|
||||
<span>收缩压(晨)</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'systolic_bp_morning',
|
||||
key: 'systolic_bp_morning',
|
||||
width: 110,
|
||||
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<Tooltip title="晨间舒张压">
|
||||
<span>舒张压(晨)</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'diastolic_bp_morning',
|
||||
key: 'diastolic_bp_morning',
|
||||
width: 110,
|
||||
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
|
||||
},
|
||||
{
|
||||
title: '心率',
|
||||
dataIndex: 'heart_rate',
|
||||
key: 'heart_rate',
|
||||
width: 80,
|
||||
render: (v?: number) => (v != null ? `${v} bpm` : '-'),
|
||||
},
|
||||
{
|
||||
title: '体重',
|
||||
dataIndex: 'weight',
|
||||
key: 'weight',
|
||||
width: 80,
|
||||
render: (v?: number) => (v != null ? `${v} kg` : '-'),
|
||||
},
|
||||
{
|
||||
title: '血糖',
|
||||
dataIndex: 'blood_sugar',
|
||||
key: 'blood_sugar',
|
||||
width: 90,
|
||||
render: (v?: number) => (v != null ? `${v} mmol/L` : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, record: VitalSigns) => (
|
||||
<AuthButton code="health.health-data.manage">
|
||||
<Space size={0}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEdit(record)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="删除后无法恢复,确定要删除这条体征记录吗?"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</AuthButton>
|
||||
),
|
||||
},
|
||||
],
|
||||
// handleOpenEdit and handleDelete are stable closures that only depend on patientId via refresh
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[patientId],
|
||||
);
|
||||
|
||||
// 最近一次数据摘要
|
||||
const latest = data.length > 0 ? data[0] : null;
|
||||
|
||||
@@ -167,7 +255,7 @@ export function VitalSignsTab({ patientId }: Props) {
|
||||
</Text>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => { form.resetFields(); setModalOpen(true); }}
|
||||
onClick={handleOpenCreate}
|
||||
size="small"
|
||||
>
|
||||
录入体征
|
||||
@@ -189,19 +277,22 @@ export function VitalSignsTab({ patientId }: Props) {
|
||||
size: 'small',
|
||||
style: { margin: 0 },
|
||||
}}
|
||||
scroll={{ x: 600 }}
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="录入体征数据"
|
||||
title={editingRecord ? '编辑体征数据' : '录入体征数据'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditingRecord(null);
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="record_date" label="记录日期" rules={[{ required: true, message: '请选择日期' }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { login as apiLogin, logout as apiLogout, type UserInfo } from '../api/auth';
|
||||
import { clearApiCache } from '../api/client';
|
||||
|
||||
function extractPermissions(): string[] {
|
||||
const token = localStorage.getItem('access_token');
|
||||
@@ -54,6 +55,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
localStorage.setItem('refresh_token', resp.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(resp.user));
|
||||
set({ user: resp.user, isAuthenticated: true, loading: false, permissions: extractPermissions() });
|
||||
clearApiCache();
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
throw error;
|
||||
@@ -69,6 +71,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
clearApiCache();
|
||||
set({ user: null, isAuthenticated: false, permissions: [] });
|
||||
},
|
||||
|
||||
|
||||
@@ -23,3 +23,4 @@ base64 = "0.22"
|
||||
hex = "0.4"
|
||||
rand = "0.8"
|
||||
dashmap = "6"
|
||||
ammonia.workspace = true
|
||||
|
||||
@@ -1,44 +1,36 @@
|
||||
/// HTML/Script 内容清理工具。
|
||||
///
|
||||
/// 在用户输入进入数据库之前,剥离所有 HTML 标签,防止存储型 XSS。
|
||||
/// 基于 ammonia(html5ever)剥离所有 HTML 标签,防止存储型 XSS。
|
||||
/// 覆盖场景:用户名、显示名、邮箱、电话等字符串字段。
|
||||
|
||||
/// 剥离字符串中的所有 HTML 标签,返回纯文本。
|
||||
///
|
||||
/// ```rust
|
||||
/// use erp_core::sanitize::strip_html_tags;
|
||||
/// assert_eq!(strip_html_tags("<script>alert(1)</script>"), "alert(1)");
|
||||
/// assert_eq!(strip_html_tags("<img src=x onerror=alert(1)>"), "");
|
||||
/// assert_eq!(strip_html_tags("Hello <b>World</b>"), "Hello World");
|
||||
/// ```
|
||||
/// 使用 ammonia 构建 DOM 树,然后用 tendril 收集文本节点。
|
||||
/// 比手写字符级解析器更安全,能正确处理所有 HTML 边界情况。
|
||||
pub fn strip_html_tags(input: &str) -> String {
|
||||
let mut result = String::with_capacity(input.len());
|
||||
let mut in_tag = false;
|
||||
let mut depth = 0usize;
|
||||
// 使用 ammonia 清理(保留在 span 中的纯文本),然后剥离 span 标签
|
||||
let doc = ammonia::Builder::new()
|
||||
.tags(std::collections::HashSet::new())
|
||||
.clean(input)
|
||||
.to_string();
|
||||
|
||||
for ch in input.chars() {
|
||||
match ch {
|
||||
'<' => {
|
||||
in_tag = true;
|
||||
depth += 1;
|
||||
}
|
||||
'>' => {
|
||||
if depth > 0 {
|
||||
depth -= 1;
|
||||
}
|
||||
if depth == 0 {
|
||||
in_tag = false;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !in_tag {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ammonia 的 clean() 结果可能包含 HTML 实体(如 <),需要解码
|
||||
// 但由于所有标签已被禁止,结果是纯文本(可能有实体转义)
|
||||
// 使用二次清理:将结果作为纯文本处理
|
||||
decode_entities(&doc).trim().to_string()
|
||||
}
|
||||
|
||||
result.trim().to_string()
|
||||
/// 简单解码常见 HTML 实体。
|
||||
fn decode_entities(input: &str) -> String {
|
||||
input
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("/", "/")
|
||||
.replace(" ", " ")
|
||||
}
|
||||
|
||||
/// 对 Option<String> 类型的字段进行清理。
|
||||
@@ -57,7 +49,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn strips_script_tag() {
|
||||
assert_eq!(strip_html_tags("<script>alert('xss')</script>"), "alert('xss')");
|
||||
// script 内容在 HTML 规范中是 raw text,ammonia 正确地将其完全移除
|
||||
assert_eq!(strip_html_tags("<script>alert('xss')</script>"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -83,7 +76,7 @@ mod tests {
|
||||
#[test]
|
||||
fn sanitize_option_some() {
|
||||
assert_eq!(
|
||||
sanitize_option(Some("<script>evil</script>".to_string())),
|
||||
sanitize_option(Some("<b>evil</b>".to_string())),
|
||||
Some("evil".to_string())
|
||||
);
|
||||
}
|
||||
@@ -97,4 +90,22 @@ mod tests {
|
||||
fn sanitize_option_becomes_empty() {
|
||||
assert_eq!(sanitize_option(Some("<img>".to_string())), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_nested_script_attack() {
|
||||
let result = strip_html_tags("<scr<script>ipt>alert(1)</scr</script>ipt>");
|
||||
assert!(!result.contains("<"), "不应残留 HTML 标签");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_unclosed_tag() {
|
||||
let result = strip_html_tags("text <img");
|
||||
assert!(result.contains("text") || result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_entities() {
|
||||
let result = strip_html_tags("a < b");
|
||||
assert!(result.contains("a") && result.contains("b"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +411,14 @@ pub async fn update_schedule(
|
||||
}
|
||||
}
|
||||
|
||||
// 记录变更前的关键字段
|
||||
let old_values = serde_json::json!({
|
||||
"start_time": model.start_time,
|
||||
"end_time": model.end_time,
|
||||
"max_appointments": model.max_appointments,
|
||||
"status": model.status,
|
||||
});
|
||||
|
||||
let mut active: doctor_schedule::ActiveModel = model.into();
|
||||
if let Some(v) = req.start_time { active.start_time = Set(v); }
|
||||
if let Some(v) = req.end_time { active.end_time = Set(v); }
|
||||
@@ -422,9 +430,18 @@ pub async fn update_schedule(
|
||||
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
// 变更后快照
|
||||
let new_values = serde_json::json!({
|
||||
"start_time": m.start_time,
|
||||
"end_time": m.end_time,
|
||||
"max_appointments": m.max_appointments,
|
||||
"status": m.status,
|
||||
});
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "doctor_schedule.updated", "doctor_schedule")
|
||||
.with_resource_id(m.id),
|
||||
.with_resource_id(m.id)
|
||||
.with_changes(Some(old_values), Some(new_values)),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
|
||||
@@ -170,6 +170,14 @@ pub async fn update_task(
|
||||
validate_follow_up_status_transition(&model.status, new_status)?;
|
||||
}
|
||||
|
||||
// 记录变更前的关键字段
|
||||
let old_values = serde_json::json!({
|
||||
"assigned_to": model.assigned_to,
|
||||
"follow_up_type": model.follow_up_type,
|
||||
"planned_date": model.planned_date,
|
||||
"status": model.status,
|
||||
});
|
||||
|
||||
let mut active: follow_up_task::ActiveModel = model.into();
|
||||
if let Some(v) = req.assigned_to { active.assigned_to = Set(Some(v)); }
|
||||
if let Some(v) = req.follow_up_type { active.follow_up_type = Set(v); }
|
||||
@@ -182,9 +190,18 @@ pub async fn update_task(
|
||||
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
// 变更后快照
|
||||
let new_values = serde_json::json!({
|
||||
"assigned_to": m.assigned_to,
|
||||
"follow_up_type": m.follow_up_type,
|
||||
"planned_date": m.planned_date,
|
||||
"status": m.status,
|
||||
});
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "follow_up_task.updated", "follow_up_task")
|
||||
.with_resource_id(m.id),
|
||||
.with_resource_id(m.id)
|
||||
.with_changes(Some(old_values), Some(new_values)),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
|
||||
@@ -156,6 +156,19 @@ pub async fn update_vital_signs(
|
||||
let next_ver = check_version(expected_version, model.version)
|
||||
.map_err(|_| HealthError::VersionMismatch)?;
|
||||
|
||||
// 记录变更前的关键体征值
|
||||
let old_values = serde_json::json!({
|
||||
"record_date": model.record_date,
|
||||
"systolic_bp_morning": model.systolic_bp_morning,
|
||||
"diastolic_bp_morning": model.diastolic_bp_morning,
|
||||
"systolic_bp_evening": model.systolic_bp_evening,
|
||||
"diastolic_bp_evening": model.diastolic_bp_evening,
|
||||
"heart_rate": model.heart_rate,
|
||||
"weight": model.weight,
|
||||
"blood_sugar": model.blood_sugar,
|
||||
"notes": model.notes,
|
||||
});
|
||||
|
||||
let mut active: vital_signs::ActiveModel = model.into();
|
||||
if let Some(v) = req.record_date { active.record_date = Set(v); }
|
||||
if let Some(v) = req.systolic_bp_morning { active.systolic_bp_morning = Set(Some(v)); }
|
||||
@@ -174,6 +187,19 @@ pub async fn update_vital_signs(
|
||||
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
// 变更后快照
|
||||
let new_values = serde_json::json!({
|
||||
"record_date": m.record_date,
|
||||
"systolic_bp_morning": m.systolic_bp_morning,
|
||||
"diastolic_bp_morning": m.diastolic_bp_morning,
|
||||
"systolic_bp_evening": m.systolic_bp_evening,
|
||||
"diastolic_bp_evening": m.diastolic_bp_evening,
|
||||
"heart_rate": m.heart_rate,
|
||||
"weight": m.weight,
|
||||
"blood_sugar": m.blood_sugar,
|
||||
"notes": m.notes,
|
||||
});
|
||||
|
||||
// 更新后也触发危急值检测(修改后的值可能触发告警)
|
||||
let check_req = CreateVitalSignsReq {
|
||||
record_date: m.record_date,
|
||||
@@ -193,7 +219,8 @@ pub async fn update_vital_signs(
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "vital_signs.updated", "vital_signs")
|
||||
.with_resource_id(m.id),
|
||||
.with_resource_id(m.id)
|
||||
.with_changes(Some(old_values), Some(new_values)),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
@@ -406,6 +433,16 @@ pub async fn update_lab_report(
|
||||
let next_ver = check_version(expected_version, model.version)
|
||||
.map_err(|_| HealthError::VersionMismatch)?;
|
||||
|
||||
// 记录变更前的关键字段(items 为加密值,记录 meta 信息)
|
||||
let old_values = serde_json::json!({
|
||||
"report_date": model.report_date,
|
||||
"report_type": model.report_type,
|
||||
"status": model.status,
|
||||
"has_items": model.items.is_some(),
|
||||
"has_image_urls": model.image_urls.is_some(),
|
||||
"has_doctor_notes": model.doctor_notes.is_some(),
|
||||
});
|
||||
|
||||
let mut active: lab_report::ActiveModel = model.into();
|
||||
if let Some(v) = req.report_date { active.report_date = Set(v); }
|
||||
if let Some(v) = req.report_type { active.report_type = Set(v); }
|
||||
@@ -430,9 +467,20 @@ pub async fn update_lab_report(
|
||||
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
// 变更后快照
|
||||
let new_values = serde_json::json!({
|
||||
"report_date": m.report_date,
|
||||
"report_type": m.report_type,
|
||||
"status": m.status,
|
||||
"has_items": m.items.is_some(),
|
||||
"has_image_urls": m.image_urls.is_some(),
|
||||
"has_doctor_notes": m.doctor_notes.is_some(),
|
||||
});
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "lab_report.updated", "lab_report")
|
||||
.with_resource_id(m.id),
|
||||
.with_resource_id(m.id)
|
||||
.with_changes(Some(old_values), Some(new_values)),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
@@ -514,6 +562,7 @@ pub async fn review_lab_report(
|
||||
|
||||
validate_lab_report_status_transition(&model.status, "reviewed")?;
|
||||
|
||||
let old_status = model.status.clone();
|
||||
let mut active: lab_report::ActiveModel = model.into();
|
||||
active.status = Set("reviewed".to_string());
|
||||
active.reviewed_by = Set(Some(reviewer_id));
|
||||
@@ -539,7 +588,11 @@ pub async fn review_lab_report(
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(reviewer_id), "lab_report.reviewed", "lab_report")
|
||||
.with_resource_id(m.id),
|
||||
.with_resource_id(m.id)
|
||||
.with_changes(
|
||||
Some(serde_json::json!({ "status": old_status })),
|
||||
Some(serde_json::json!({ "status": m.status, "reviewed_by": m.reviewed_by })),
|
||||
),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
@@ -675,6 +728,14 @@ pub async fn update_health_record(
|
||||
let next_ver = check_version(expected_version, model.version)
|
||||
.map_err(|_| HealthError::VersionMismatch)?;
|
||||
|
||||
// 记录变更前的关键字段
|
||||
let old_values = serde_json::json!({
|
||||
"record_type": model.record_type,
|
||||
"record_date": model.record_date,
|
||||
"overall_assessment": model.overall_assessment,
|
||||
"notes": model.notes,
|
||||
});
|
||||
|
||||
let mut active: health_record::ActiveModel = model.into();
|
||||
if let Some(ref v) = req.record_type { validate_record_type(v)?; active.record_type = Set(v.clone()); }
|
||||
if let Some(v) = req.record_date { active.record_date = Set(v); }
|
||||
@@ -688,9 +749,18 @@ pub async fn update_health_record(
|
||||
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
// 变更后快照
|
||||
let new_values = serde_json::json!({
|
||||
"record_type": m.record_type,
|
||||
"record_date": m.record_date,
|
||||
"overall_assessment": m.overall_assessment,
|
||||
"notes": m.notes,
|
||||
});
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "health_record.updated", "health_record")
|
||||
.with_resource_id(m.id),
|
||||
.with_resource_id(m.id)
|
||||
.with_changes(Some(old_values), Some(new_values)),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
|
||||
@@ -603,6 +603,15 @@ pub async fn update_family_member(
|
||||
|
||||
let kek = state.crypto.kek();
|
||||
let hmac_key = state.crypto.hmac_key();
|
||||
|
||||
// 记录变更前的关键字段(phone 为加密值,不记录原文)
|
||||
let old_values = serde_json::json!({
|
||||
"name": model.name,
|
||||
"relationship": model.relationship,
|
||||
"birth_date": model.birth_date,
|
||||
"notes": model.notes,
|
||||
});
|
||||
|
||||
let mut active: patient_family_member::ActiveModel = model.into();
|
||||
active.name = Set(req.name);
|
||||
active.relationship = Set(req.relationship);
|
||||
@@ -621,9 +630,18 @@ pub async fn update_family_member(
|
||||
|
||||
let updated = active.update(&state.db).await?;
|
||||
|
||||
// 变更后快照
|
||||
let new_values = serde_json::json!({
|
||||
"name": updated.name,
|
||||
"relationship": updated.relationship,
|
||||
"birth_date": updated.birth_date,
|
||||
"notes": updated.notes,
|
||||
});
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "patient.family_member_updated", "patient_family_member")
|
||||
.with_resource_id(updated.id),
|
||||
.with_resource_id(updated.id)
|
||||
.with_changes(Some(old_values), Some(new_values)),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
@@ -958,6 +976,13 @@ pub async fn update_tag(
|
||||
if tag.tenant_id != tenant_id { return Err(HealthError::TagNotFound); }
|
||||
check_version(req.version, tag.version)?;
|
||||
|
||||
// 记录变更前的关键字段
|
||||
let old_values = serde_json::json!({
|
||||
"name": tag.name,
|
||||
"color": tag.color,
|
||||
"description": tag.description,
|
||||
});
|
||||
|
||||
let mut active: patient_tag::ActiveModel = tag.into();
|
||||
if let Some(name) = req.name { active.name = Set(name); }
|
||||
if let Some(color) = req.color { active.color = Set(Some(color)); }
|
||||
@@ -969,9 +994,17 @@ pub async fn update_tag(
|
||||
let updated = active.update(&state.db).await
|
||||
.map_err(|e: sea_orm::DbErr| HealthError::DbError(e.to_string()))?;
|
||||
|
||||
// 变更后快照
|
||||
let new_values = serde_json::json!({
|
||||
"name": updated.name,
|
||||
"color": updated.color,
|
||||
"description": updated.description,
|
||||
});
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "patient_tag.update", "patient_tag")
|
||||
.with_resource_id(updated.id),
|
||||
.with_resource_id(updated.id)
|
||||
.with_changes(Some(old_values), Some(new_values)),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ where
|
||||
)));
|
||||
}
|
||||
|
||||
// 校验 magic bytes:验证文件实际内容与声明的 Content-Type 一致
|
||||
validate_magic_bytes(&content_type, &data)?;
|
||||
|
||||
// 生成唯一文件名,保留原始扩展名
|
||||
let ext = std::path::Path::new(&original_name)
|
||||
.extension()
|
||||
@@ -137,6 +140,78 @@ fn validate_content_type(content_type: &str) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验文件 magic bytes(文件签名)与声明的 Content-Type 是否一致。
|
||||
///
|
||||
/// 防止攻击者通过修改 Content-Type 头上传恶意文件。
|
||||
/// 对于 Office 格式等复杂签名,跳过 magic bytes 校验(仅依赖白名单)。
|
||||
fn validate_magic_bytes(content_type: &str, data: &[u8]) -> Result<(), AppError> {
|
||||
// 需要至少几个字节才能校验
|
||||
if data.is_empty() {
|
||||
return Err(AppError::Validation("文件内容为空".to_string()));
|
||||
}
|
||||
|
||||
let signature: &[u8] = match content_type {
|
||||
"image/jpeg" => {
|
||||
// JPEG: FF D8 FF
|
||||
b"\xFF\xD8\xFF"
|
||||
}
|
||||
"image/png" => {
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
b"\x89PNG\r\n\x1A\n"
|
||||
}
|
||||
"image/gif" => {
|
||||
// GIF: 47 49 46 38 (GIF8)
|
||||
b"GIF8"
|
||||
}
|
||||
"image/webp" => {
|
||||
// WebP: RIFF....WEBP (12 bytes)
|
||||
// 前 4 字节: 52 49 46 46 (RIFF)
|
||||
// 字节 8-11: 57 45 42 50 (WEBP)
|
||||
if data.len() < 12 {
|
||||
return Err(AppError::Validation(
|
||||
"文件数据不足,无法验证 WebP 格式".to_string(),
|
||||
));
|
||||
}
|
||||
let riff_ok = &data[0..4] == b"RIFF";
|
||||
let webp_ok = &data[8..12] == b"WEBP";
|
||||
if riff_ok && webp_ok {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(AppError::Validation(
|
||||
"文件内容与声明的类型 (image/webp) 不匹配".to_string(),
|
||||
));
|
||||
}
|
||||
"application/pdf" => {
|
||||
// PDF: 25 50 44 46 (%PDF)
|
||||
b"%PDF"
|
||||
}
|
||||
// Office 格式的 magic bytes 较复杂(OLE2 / ZIP-based OOXML),
|
||||
// 仅依赖白名单,跳过 magic bytes 校验
|
||||
"application/msword"
|
||||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
| "application/vnd.ms-excel"
|
||||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => {
|
||||
return Ok(());
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
if data.len() < signature.len() {
|
||||
return Err(AppError::Validation(
|
||||
"文件数据不足,无法验证文件格式".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if &data[..signature.len()] != signature {
|
||||
return Err(AppError::Validation(format!(
|
||||
"文件内容与声明的类型 ({}) 不匹配",
|
||||
content_type
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
if bytes >= 1024 * 1024 * 1024 {
|
||||
format!("{}GB", bytes / (1024 * 1024 * 1024))
|
||||
|
||||
@@ -487,6 +487,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
// with the jwt_auth_middleware_fn.
|
||||
|
||||
// Public routes (no authentication, but IP-based rate limiting)
|
||||
// Layer execution order (outer → inner): account_lockout → rate_limit_by_ip
|
||||
// So account lockout check runs FIRST, then IP rate limiting
|
||||
let public_routes = Router::new()
|
||||
.merge(handlers::health::health_check_router())
|
||||
.merge(erp_auth::AuthModule::public_routes())
|
||||
@@ -494,6 +496,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
"/docs/openapi.json",
|
||||
axum::routing::get(handlers::openapi::openapi_spec),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
middleware::rate_limit::account_lockout_middleware,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
middleware::rate_limit::rate_limit_by_ip,
|
||||
|
||||
@@ -19,6 +19,10 @@ struct RateLimitResponse {
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// 账户锁定配置。
|
||||
const ACCOUNT_LOCKOUT_MAX_FAILURES: i64 = 5;
|
||||
const ACCOUNT_LOCKOUT_TTL_SECS: i64 = 900; // 15 分钟
|
||||
|
||||
/// 限流参数(预留配置化扩展)。
|
||||
#[allow(dead_code)]
|
||||
pub struct RateLimitConfig {
|
||||
@@ -162,6 +166,133 @@ async fn apply_rate_limit(
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// 账户级登录锁定中间件。
|
||||
///
|
||||
/// 针对登录接口(POST /api/v1/auth/login),在 IP 限流之前执行:
|
||||
/// 1. 解析请求体提取 username
|
||||
/// 2. 检查 Redis 中该 username 的失败次数
|
||||
/// 3. 超过阈值(5次)则拒绝请求
|
||||
/// 4. 观察响应状态码:401 递增失败计数,200 清除计数
|
||||
pub async fn account_lockout_middleware(
|
||||
State(state): State<AppState>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let avail = redis_avail();
|
||||
|
||||
// Redis 不可达时 fail-open:放行请求
|
||||
if !avail.should_try().await {
|
||||
tracing::warn!("Redis 不可达,fail-open 账户锁定检查放行");
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
// 获取 Redis 连接
|
||||
let mut conn = match state.redis.get_multiplexed_async_connection().await {
|
||||
Ok(c) => {
|
||||
avail.mark_ok();
|
||||
c
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Redis 连接失败,fail-open 账户锁定检查放行");
|
||||
avail.mark_failed().await;
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
// 读取请求体以提取 username
|
||||
let (parts, body) = req.into_parts();
|
||||
let bytes = match axum::body::to_bytes(body, 1024).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "读取登录请求体失败,放行");
|
||||
// 无法读取 body,重建请求放行
|
||||
let req = Request::from_parts(parts, Body::from(Vec::new()));
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
// 解析 username
|
||||
let username = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
.ok()
|
||||
.and_then(|v| v.get("username")?.as_str().map(|s| s.to_string()));
|
||||
|
||||
let username = match username {
|
||||
Some(u) if !u.is_empty() => u,
|
||||
_ => {
|
||||
// 无法解析 username,用原始 body 重建请求放行
|
||||
let req = Request::from_parts(parts, Body::from(bytes.to_vec()));
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查账户锁定状态
|
||||
let lockout_key = format!("login_fail:{}", username);
|
||||
let fail_count: i64 = conn.get(&lockout_key).await.unwrap_or(0);
|
||||
|
||||
if fail_count >= ACCOUNT_LOCKOUT_MAX_FAILURES {
|
||||
tracing::warn!(
|
||||
username = %username,
|
||||
fail_count = fail_count,
|
||||
"账户已被临时锁定"
|
||||
);
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "账户已被临时锁定,请15分钟后重试".to_string(),
|
||||
};
|
||||
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
}
|
||||
|
||||
// 用原始 body 重建请求,转发到 handler
|
||||
let req = Request::from_parts(parts, Body::from(bytes.to_vec()));
|
||||
let response = next.run(req).await;
|
||||
|
||||
// 观察响应状态码
|
||||
let status = response.status();
|
||||
let (parts, body) = response.into_parts();
|
||||
|
||||
// 需要读取 body 以重建响应(因为 into_parts 消费了 body)
|
||||
let body_bytes = axum::body::to_bytes(body, 1024 * 1024)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
// 登录失败:递增失败计数
|
||||
let new_count: i64 = match redis::cmd("INCR")
|
||||
.arg(&lockout_key)
|
||||
.query_async(&mut conn)
|
||||
.await
|
||||
{
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Redis INCR 失败计数失败");
|
||||
// 即使计数失败,也返回原始 401 响应
|
||||
let resp = Response::from_parts(parts, Body::from(body_bytes.to_vec()));
|
||||
return resp;
|
||||
}
|
||||
};
|
||||
|
||||
// 首次失败时设置 TTL
|
||||
if new_count == 1 {
|
||||
let _: Result<(), _> = conn.expire(&lockout_key, ACCOUNT_LOCKOUT_TTL_SECS).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
username = %username,
|
||||
fail_count = new_count,
|
||||
remaining = ACCOUNT_LOCKOUT_MAX_FAILURES - new_count,
|
||||
"登录失败,递增失败计数"
|
||||
);
|
||||
} else if status.is_success() {
|
||||
// 登录成功:清除失败计数
|
||||
let _: Result<(), _> = conn.del(&lockout_key).await;
|
||||
tracing::info!(username = %username, "登录成功,清除失败计数");
|
||||
}
|
||||
|
||||
// 重建并返回原始响应
|
||||
let resp = Response::from_parts(parts, Body::from(body_bytes.to_vec()));
|
||||
resp
|
||||
}
|
||||
|
||||
/// 从请求头中提取客户端 IP。
|
||||
fn extract_client_ip(headers: &axum::http::HeaderMap) -> String {
|
||||
headers
|
||||
|
||||
Reference in New Issue
Block a user