From 787e64d9a95eb8ac9c90e03e8508e350decbf3f7 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 26 Apr 2026 21:47:26 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=89=8D=E7=AB=AF=E6=B7=B1=E5=BA=A6?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E5=85=A8=E9=87=8F=E4=BF=AE=E5=A4=8D=20?= =?UTF-8?q?=E2=80=94=20=E5=AE=89=E5=85=A8/=E5=8A=9F=E8=83=BD/=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=B4=A8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 严重 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 --- CLAUDE.md | 358 ++++-------------- Cargo.lock | 231 +++++++++++ Cargo.toml | 3 + apps/web/src/App.tsx | 13 +- apps/web/src/api/auth.ts | 8 - apps/web/src/api/client.ts | 37 +- apps/web/src/api/health/patients.ts | 1 + apps/web/src/components/AuthGuard.tsx | 13 - apps/web/src/pages/health/AppointmentList.tsx | 43 ++- apps/web/src/pages/health/ArticleEditor.tsx | 33 +- .../health/components/HealthRecordsTab.tsx | 117 +++++- .../pages/health/components/LabReportsTab.tsx | 104 +++-- .../pages/health/components/VitalSignsTab.tsx | 225 +++++++---- apps/web/src/stores/auth.ts | 3 + crates/erp-core/Cargo.toml | 1 + crates/erp-core/src/sanitize.rs | 79 ++-- .../src/service/appointment_service.rs | 19 +- .../src/service/follow_up_service.rs | 19 +- .../src/service/health_data_service.rs | 78 +++- .../erp-health/src/service/patient_service.rs | 37 +- crates/erp-server/src/handlers/upload.rs | 75 ++++ crates/erp-server/src/main.rs | 6 + .../erp-server/src/middleware/rate_limit.rs | 131 +++++++ 23 files changed, 1152 insertions(+), 482 deletions(-) delete mode 100644 apps/web/src/components/AuthGuard.tsx diff --git a/CLAUDE.md b/CLAUDE.md index cd08aff..822d907 100644 --- a/CLAUDE.md +++ b/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` 自动转换为 `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` 包装 @@ -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. 提交规范 ``` (): @@ -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 客户管理插件设计规格 | - -所有设计决策以设计规格文档为准。实施计划按阶段拆分,每阶段开始前细化。 - ---- - - - - -## 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 页面) | ✅ 完成 | - - - ---- - - - -## 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`)** - +--- + +## 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 | diff --git a/Cargo.lock b/Cargo.lock index b8a034e..b17210f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index d50adfd..de443a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,3 +110,6 @@ async-stream = "0.3" # Template engine handlebars = "6" + +# HTML sanitization +ammonia = "4" diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3595f3e..ca2f3e6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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} : ; + const permissions = useAuthStore((s) => s.permissions); + + if (!isAuthenticated) return ; + + // 路由级权限检查:如果用户对某个模块完全没有权限,重定向到首页 + 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 ; + } + + return <>{children}; } const themeConfig = { diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts index 8029eba..98cb7f7 100644 --- a/apps/web/src/api/auth.ts +++ b/apps/web/src/api/auth.ts @@ -40,14 +40,6 @@ export async function login(req: LoginRequest): Promise { return data.data; } -export async function refresh(refreshToken: string): Promise { - const { data } = await client.post<{ success: boolean; data: LoginResponse }>( - '/auth/refresh', - { refresh_token: refreshToken } - ); - return data.data; -} - export async function logout(): Promise { await client.post('/auth/logout'); } diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 65d9ecc..3554193 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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 | 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; diff --git a/apps/web/src/api/health/patients.ts b/apps/web/src/api/health/patients.ts index cb7496e..0c29900 100644 --- a/apps/web/src/api/health/patients.ts +++ b/apps/web/src/api/health/patients.ts @@ -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; diff --git a/apps/web/src/components/AuthGuard.tsx b/apps/web/src/components/AuthGuard.tsx deleted file mode 100644 index aad6ffe..0000000 --- a/apps/web/src/components/AuthGuard.tsx +++ /dev/null @@ -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}; -} diff --git a/apps/web/src/pages/health/AppointmentList.tsx b/apps/web/src/pages/health/AppointmentList.tsx index 09bd614..c4c9a6b 100644 --- a/apps/web/src/pages/health/AppointmentList.tsx +++ b/apps/web/src/pages/health/AppointmentList.tsx @@ -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(undefined); const [nameCache, setNameCache] = useState>({}); + // 排班校验 + const [scheduleHint, setScheduleHint] = useState(null); + const [selectedDate, setSelectedDate] = useState(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 && ( + + )}
- + setSelectedDate(d ? d.format('YYYY-MM-DD') : null)} /> diff --git a/apps/web/src/pages/health/ArticleEditor.tsx b/apps/web/src/pages/health/ArticleEditor.tsx index 1a64be3..268d64e 100644 --- a/apps/web/src/pages/health/ArticleEditor.tsx +++ b/apps/web/src/pages/health/ArticleEditor.tsx @@ -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 { diff --git a/apps/web/src/pages/health/components/HealthRecordsTab.tsx b/apps/web/src/pages/health/components/HealthRecordsTab.tsx index f89e092..c6ef9de 100644 --- a/apps/web/src/pages/health/components/HealthRecordsTab.tsx +++ b/apps/web/src/pages/health/components/HealthRecordsTab.tsx @@ -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 = { inpatient: '住院', }; -const columns = [ - { title: '记录类型', dataIndex: 'record_type', key: 'record_type', width: 120, render: (v: string) => {RECORD_TYPE_MAP[v] || v} }, - { 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(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(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) => {RECORD_TYPE_MAP[v] || v} }, + { 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) => ( + + + + + + handleDelete(record)} + okText="删除" + cancelText="取消" + > + + + + + ), + }, + ]; + return (
-
@@ -87,15 +162,15 @@ export function HealthRecordsTab({ patientId }: Props) { }} /> setModalOpen(false)} + onCancel={() => { setModalOpen(false); setEditingRecord(null); }} onOk={() => form.submit()} confirmLoading={submitting} destroyOnClose width={520} > - +