Compare commits

...

10 Commits

Author SHA1 Message Date
iven
ca50d32f6e feat(health): 添加 erp-health 健康管理模块骨架
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
新建 erp-health 原生 Rust crate,覆盖设计规格中定义的 5 大业务域:

- 16 个 SeaORM Entity(患者/家属/标签/医生/健康档案/体征/化验单/预约/排班/随访/咨询等)
- 16 表数据库迁移(含索引、外键、默认值、可回滚)
- 40+ API 路由骨架(患者管理/健康数据/预约排班/随访/咨询/医生管理)
- 12 个权限声明(health.patient/health-data/appointment/follow-up/consultation/doctor 各 .list/.manage)
- DTO / Service / Handler / Event 四层架构,Service 使用 todo!() 占位
- erp-server 集成:模块注册 + AppState FromRef 桥接 + 路由挂载

同步更新 CLAUDE.md 项目进度、wiki 知识库、设计规格文档。
2026-04-23 19:59:22 +08:00
iven
5ac8e18d74 fix(web): 修复 visible_when 表达式评估器 !=/||/&& 支持 + 添加 validation 前端校验
- exprEvaluator: 新增 neq 类型修复 != 操作符被当作 == 处理的 bug
- exprEvaluator: 支持 || 和 && 作为 OR/AND 的别名
- PluginCRUDPage: 读取 field.validation.pattern 添加表单正则校验规则
2026-04-21 00:19:10 +08:00
iven
89fc482d99 feat(web): 采用 UI UX Pro Max Soft UI Evolution 设计系统
从 Pinterest 风格切换到 Soft UI Evolution 设计系统,使用 UI UX Pro Max
推理引擎生成适合跨行业 ERP 业务用户的专业设计方案。

设计变更:
- 主色从 Pinterest Red (#e60023) 切换到 Trust Blue (#2563EB)
- 字体从系统默认切换到 Noto Sans SC(中文优先)
- 圆角从 16-20px 调整到 10-12px(专业但不夸张)
- 中性色从暖橄榄调切换到 Slate 石板蓝调
- 成功色 #103c25 → #059669,警告色 #b56e1a → #d97706
- 暗色模式从暖黑 (#1a1a18) 切换到深海军蓝 (#0f172a)

涉及文件:DESIGN.md + index.css + App.tsx + 24 个组件文件
2026-04-20 23:27:24 +08:00
iven
85e732cf12 feat(web): 从 Notion 风格切换到 Pinterest 设计系统
- 替换 DESIGN.md 为 Pinterest 设计规格(暖色调、红色主题、大圆角)
- 更新 CSS 变量:主色 #0075de→#e60023, 圆角 4px→16px, 背景 #f6f5f4→#f6f6f3
- 更新 Ant Design 主题令牌:更大圆角、Pinterest 色板、更大触控目标
- 批量更新 24 个页面/组件文件中的硬编码颜色值
- 暗色模式同步适配 Pinterest 暖色调暗色方案
2026-04-20 22:13:20 +08:00
iven
8f3d2d58e7 feat(web): 采用 Notion 设计系统 — 暖色调 + 白色侧边栏 + Inter 字体
引入 Notion 风格的 DESIGN.md 设计系统文件,并全面重构前端 UI:

- 主色从 Indigo (#4F46E5) 迁移到 Notion Blue (#0075de)
- 页面背景从冷灰 (#F1F5F9) 迁移到暖白 (#f6f5f4)
- 侧边栏从深色 (#0F172A) 迁移到白色,活跃项用蓝色指示
- 文字从 Slate 冷色迁移到暖灰系列 (Warm Gray 500/300)
- 圆角从 8px 缩小到 4px(按钮/输入),8px(卡片)
- 阴影改为多层超轻 Notion 风格(最大 opacity 0.05)
- 字体优先使用 Inter,保留中文回退
- 暗色模式适配暖黑色调 (#191918)
- 更新 27 个前端文件的硬编码颜色值
2026-04-20 13:08:22 +08:00
iven
40b37cc776 feat(plugin,freelance,itops,web): P5-P6 dashboard widgets 平台扩展 + 仪表盘声明
P5 平台扩展:
- manifest.rs: Dashboard 变体新增 widgets 字段
- manifest.rs: 定义 PluginWidget/StatCard/ActionQuery 类型
- 前端: 扩展 DashboardWidget 类型支持 stat_cards/action_list/funnel/card_list
- 前端: 新增 4 个 widget 渲染器 (StatCardsWidget/ActionListWidget/FunnelStageWidget/CardListWidget)
- 前端: PluginDashboardPage widget 数据加载支持新类型

P6 仪表盘 widgets:
- freelance: 工作台仪表盘 4 个 widgets (财务概览/紧急待办/商机漏斗/活跃项目)
- itops: 新增运维概览仪表盘 2 个 widgets (运维概览/紧急待办)
2026-04-20 09:35:27 +08:00
iven
301178067c feat(freelance,itops): P1-P4 智能业务引擎 + PDF 模板
freelance (P1+P3):
- settings: 7 配置项 (公司/财务/提醒)
- trigger_events: 5 个触发事件 (商机/合同/发票/任务/支出)
- cascade: 5 处级联过滤 (合同→商机/报价, 发票→项目/合同, 工时→任务)
- visible_when: 4 处条件显示 (收款日期/已付金额/实际工时/总金额)
- validation: 2 处校验 (手机号/邮箱)
- templates: 3 个 PDF 模板 (报价单/发票/合同)

itops (P2+P4):
- settings: 4 配置项 (SLA/提醒)
- trigger_events: 4 个触发事件 (工单/合同/巡检)
- cascade: 2 处级联过滤 (工单→合同, 巡检记录→合同)
- visible_when: 6 处条件显示 (解决方案/响应时间/解决时间/关闭时间/问题/措施)
- validation: 1 处校验 (合同编号格式)
- templates: 1 个 PDF 模板 (维保合同)
2026-04-20 09:15:57 +08:00
iven
7e063a7e88 docs(plan): freelance/itops 插件增强实施计划 — P1-P6 六阶段
P1-P4 纯 plugin.toml(settings/trigger_events/cascade/visible_when/validation/templates)
P5 平台 manifest.rs + 前端 widgets 扩展
P6 两个插件仪表盘 widgets 声明
2026-04-20 07:14:30 +08:00
iven
bcc6662add docs(spec): 修复 spec 审查问题 — cascade/validation 标注为修改已有字段,dashboard 标注平台依赖
- CRITICAL: 明确 dashboard widgets 需要平台 manifest.rs 扩展
- HIGH: cascade/validation/visible_when 均改为"已有字段追加属性"
- HIGH: visible_when 提供完整 TOML 语法
- MEDIUM: 模板引擎说明(Handlebars)和关系解析机制
- MEDIUM: itops validation 补充字段上下文
- MEDIUM: itops dashboard 明确插入位置
2026-04-20 00:50:29 +08:00
iven
f4afc969bd docs(spec): 自由职业者/IT运维插件增强设计规格
三层增强方案:智能业务引擎 + 仪表盘重构 + 专业输出
纯插件层实现,无需修改平台代码
2026-04-20 00:43:34 +08:00
97 changed files with 9540 additions and 1596 deletions

View File

@@ -1,26 +1,26 @@
@wiki/index.md
整个项目对话都使用中文进行,包括文档、代码注释、事件名称等。
# ERP 平台底座 — 协作与实现规则
# HMS 健康管理平台 — 协作与实现规则
> **ERP Platform Base** 是一个模块化的商业 SaaS ERP 底座,目标是提供核心基础设施(身份权限工作流消息配置),使行业业务模块(进销存、生产、财务等)可以快速插接
> **Health Management System (HMS)** — 面向体检中心/医疗机构的综合健康管理平台。从 ERP 底座分叉独立,继承身份权限/工作流/消息/配置等基础能力,`erp-health` 原生模块承载医疗业务
> **当前阶段: Phase 1 基础设施搭建。** 从零构建 Rust workspace + Web 前端 + 核心共享层
> **当前阶段: erp-health 模块开发。** 设计规格已确认,开始实施
## 1. 项目定位
### 1.1 这是什么
一个 **底座 + 行业插件** 架构的商业 SaaS ERP 平台:
一个 **健康管理 + ERP 基础设施** 架构的医疗 SaaS 平台:
- **全功能底座** — 身份权限、工作流引擎、消息中心、系统配置
- **渐进式扩展** — 从小微企业起步,逐步支持中大型企业
- **医疗核心** — 患者管理、健康数据、预约排班、随访管理、咨询管理(原生 Rust 模块 erp-health
- **基础底座** — 身份权限、工作流引擎、消息中心、系统配置(继承自 ERP
- **多租户 + 私有化** — 默认 SaaS 共享数据库隔离,支持独立 schema 私有部署
- **Web 优先** — 浏览器 SPA 是主力,可选 Tauri 桌面端用于特定行业场景
- **Web 优先** — 浏览器 SPA 是 PC 管理后台主力,小程序(患者端/医护端)独立开发
### 1.2 决策原则
**任何改动都要问:这对 ERP 底座的模块化和可扩展性有帮助吗?**
**任何改动都要问:这对健康管理平台的医疗业务和可扩展性有帮助吗?**
- ✅ 完善模块接口和 trait 定义 → 最高优先
- ✅ 确保多租户隔离的正确性 → 最高优先
@@ -46,18 +46,18 @@
## 2. 项目结构
```text
erp/
hms/
├── crates/ # Rust Workspace
│ ├── erp-core/ # L1: 基础类型、错误、事件、模块 trait
│ ├── erp-common/ # L1: 共享工具、宏
│ ├── 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 18 SPA (主力前端)
│ └── web/ # Vite + React 19 SPA (主力前端)
├── packages/
│ └── ui-components/ # React 共享组件库
├── desktop/ # (可选) Tauri 桌面端,行业需要时启用
@@ -74,18 +74,18 @@ erp/
```text
erp-core (无业务依赖)
erp-common (无业务依赖)
erp-auth (→ core)
erp-config (→ core)
erp-workflow (→ core)
erp-message (→ core)
erp-health (→ core) ★ HMS 核心
erp-server (→ 所有 crate组装入口)
```
**规则:**
- `erp-core``erp-common` 不依赖任何业务 crate
- `erp-core` 不依赖任何业务 crate
- 业务 crate 之间**禁止**直接依赖,只通过事件总线和 `erp-core` trait 通信
- `erp-server` 是唯一的组装点
@@ -95,11 +95,11 @@ erp-server (→ 所有 crate组装入口)
|------|------|
| 后端框架 | Axum 0.8 + Tokio |
| ORM | SeaORM (异步、类型安全) |
| 数据库 | PostgreSQL 16+ |
| 数据库 | PostgreSQL 18 |
| 缓存 | Redis 7+ |
| 前端框架 | React 18 + TypeScript (Vite) |
| UI 组件库 | Ant Design 5 |
| 状态管理 | Zustand |
| 前端框架 | React 19 + TypeScript 6 (Vite 8) |
| UI 组件库 | Ant Design 6 |
| 状态管理 | Zustand 5 |
| 路由 | React Router 7 |
| 样式 | TailwindCSS + CSS Variables |
| API 文档 | utoipa (OpenAPI 3) |
@@ -422,7 +422,6 @@ cargo test -p erp-plugin-prototype # 运行插件集成测试
| scope | 范围 |
|-------|------|
| `core` | erp-core |
| `common` | erp-common |
| `auth` | erp-auth |
| `workflow` | erp-workflow |
| `message` | erp-message |
@@ -452,12 +451,10 @@ chore(docker): 添加 PostgreSQL 健康检查
| 文档 | 说明 |
|------|------|
| `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/plans/2026-04-10-erp-platform-base-plan.md` | 平台底座实施计划 |
| `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | WASM 插件系统设计规格 |
| `docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md` | WASM 插件原型验证计划 |
| `docs/superpowers/specs/2026-04-16-crm-plugin-design.md` | CRM 客户管理插件设计规格 |
| `docs/superpowers/plans/2026-04-16-crm-plugin-plan.md` | CRM 插件实施计划 |
所有设计决策以设计规格文档为准。实施计划按阶段拆分,每阶段开始前细化。
@@ -490,8 +487,7 @@ chore(docker): 添加 PostgreSQL 健康检查
| Crate | 功能 | 状态 |
|-------|------|------|
| erp-core | 错误类型、共享类型、事件总线、ErpModule trait、审计日志 | ✅ 完成 |
| erp-common | 共享工具 | ✅ 完成 |
| erp-server | Axum 服务入口、配置、数据库连接、CORS | ✅ 完成 |
| erp-server | Axum 服务入口、配置、数据库连接、CORS、模块注册、后台任务 | ✅ 完成 |
| erp-auth | 身份与权限 (用户/角色/权限/组织/部门/岗位/行级数据权限) | ✅ 完成 |
| erp-workflow | 工作流引擎 (BPMN 解析/Token 驱动/任务分配) | ✅ 完成 |
| erp-message | 消息中心 (CRUD/模板/订阅/通知面板) | ✅ 完成 |
@@ -501,6 +497,9 @@ chore(docker): 添加 PostgreSQL 健康检查
| 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 -->

20
Cargo.lock generated
View File

@@ -1252,6 +1252,25 @@ dependencies = [
"uuid",
]
[[package]]
name = "erp-health"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"chrono",
"erp-core",
"sea-orm",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"utoipa",
"uuid",
"validator",
]
[[package]]
name = "erp-message"
version = "0.1.0"
@@ -1365,6 +1384,7 @@ dependencies = [
"erp-auth",
"erp-config",
"erp-core",
"erp-health",
"erp-message",
"erp-plugin",
"erp-server-migration",

View File

@@ -15,6 +15,7 @@ members = [
"crates/erp-plugin-inventory",
"crates/erp-plugin-freelance",
"crates/erp-plugin-itops",
"crates/erp-health",
]
[workspace.package]
@@ -90,3 +91,4 @@ erp-workflow = { path = "crates/erp-workflow" }
erp-message = { path = "crates/erp-message" }
erp-config = { path = "crates/erp-config" }
erp-plugin = { path = "crates/erp-plugin" }
erp-health = { path = "crates/erp-health" }

273
DESIGN.md Normal file
View File

@@ -0,0 +1,273 @@
# Design System — Enterprise ERP Platform
> Generated by UI UX Pro Max | Style: Soft UI Evolution | Target: Cross-industry business users
## 1. Visual Theme & Atmosphere
A warm, professional, and approachable design that feels trustworthy for business users across all industries — manufacturing, retail, finance, services, and more. The design uses a clean white canvas with soft blue as the primary brand color, conveying reliability and clarity without feeling cold or corporate.
The Soft UI Evolution style provides subtle depth through improved shadows — softer than flat design but clearer than neumorphism. This creates a sense of modern polish that invites interaction while maintaining excellent readability and accessibility (WCAG AA+).
**Key Characteristics:**
- Clean white canvas with soft blue accent — trustworthy, professional, warm
- Subtle depth through soft shadows — modern but not flat, not skeuomorphic
- Moderate border-radius (10-12px) — friendly but not playful
- Chinese-first typography with Noto Sans SC — readable at all sizes
- Two-tier token system: CSS Variables (`--erp-*`) + Ant Design ConfigProvider
- Dual theme support: light (default) + dark mode
## 2. Color Palette & Roles
### Primary Brand
- **Primary Blue** (`#2563EB`): Primary CTA, active states, links, brand accent
- **Primary Hover** (`#1D4ED8`): Pressed/active primary state
- **Primary Light** (`#EFF6FF`): Primary background tint, subtle highlights
- **Primary Subtle** (`#DBEAFE`): Light blue backgrounds for badges, chips
### Semantic Colors
- **Success Green** (`#059669`): Success states, positive indicators, confirmations
- **Success Light** (`#ECFDF5`): Success background tint
- **Warning Amber** (`#D97706`): Warnings, pending states, attention needed
- **Warning Light** (`#FFFBEB`): Warning background tint
- **Error Red** (`#DC2626`): Errors, destructive actions, required fields
- **Error Light** (`#FEF2F2`): Error background tint
- **Info Blue** (`#0284C7`): Informational elements, tooltips, help text
### Text
- **Primary Text** (`#0F172A`): Headings, primary body text — deep navy, professional
- **Secondary Text** (`#475569`): Descriptions, labels, helper text
- **Tertiary Text** (`#94A3B8`): Placeholders, disabled text, timestamps
- **Inverse Text** (`#FFFFFF`): Text on colored/dark surfaces
### Surface & Border
- **Page Background** (`#F8FAFC`): App background — cool off-white
- **Container Background** (`#FFFFFF`): Cards, panels, modals
- **Elevated Background** (`#FFFFFF`): Dropdowns, popovers, tooltips
- **Border** (`#E2E8F0`): Default borders, dividers
- **Border Strong** (`#CBD5E1`): Emphasized borders, active card outlines
- **Hover Background** (`#F1F5F9`): Row hover, item hover backgrounds
- **Muted Background** (`#F1F5F9`): Subtle section backgrounds, table headers
### Dark Mode
- **Dark Page** (`#0F172A`): Dark app background
- **Dark Container** (`#1E293B`): Dark cards, panels
- **Dark Elevated** (`#334155`): Dark dropdowns, popovers
- **Dark Border** (`#334155`): Dark borders
- **Dark Hover** (`#1E293B`): Dark row hover
- **Dark Text Primary** (`#F8FAFC`): Dark mode primary text
- **Dark Text Secondary** (`#94A3B8`): Dark mode secondary text
## 3. Typography Rules
### Font Family
- **Primary**: `Noto Sans SC`, fallbacks: `-apple-system, system-ui, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif`
- **Monospace (optional)**: `'JetBrains Mono', 'Fira Code', Consolas, monospace`
### Hierarchy
| Role | Size | Weight | Line Height | Usage |
|------|------|--------|-------------|-------|
| Page Title | 24px (1.5rem) | 700 | 1.3 | Page headings, dialog titles |
| Section Title | 20px (1.25rem) | 600 | 1.4 | Section headings, card titles |
| Subsection | 16px (1rem) | 600 | 1.5 | Subsection labels, form group titles |
| Body | 14px (0.875rem) | 400 | 1.6 | Standard body text, table cells, descriptions |
| Caption | 12px (0.75rem) | 400 | 1.5 | Timestamps, badges, helper text, metadata |
### Principles
- **Chinese-first**: Noto Sans SC ensures excellent CJK rendering
- **Readable weights**: 400 for body, 600-700 for headings — always substantial, never thin
- **Generous line-height**: 1.5-1.6 for body text ensures comfortable reading
- **Moderate scale**: 12-24px range creates a compact, professional information hierarchy
## 4. Component Stylings
### Buttons
- **Primary**: Blue (#2563EB) background, white text, 10px radius, soft shadow
- **Secondary**: White background, slate border (#CBD5E1), dark text, 10px radius
- **Ghost**: Transparent background, primary blue text, no border
- **Danger**: Red (#DC2626) background, white text, for destructive actions
- **All buttons**: Min height 36px, 40px preferred; smooth hover transition 200ms
### Cards & Containers
- White background, 12px radius, soft shadow: `0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)`
- Hover: Slightly elevated shadow: `0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.05)`
- No thick borders — shadows and subtle background differences create depth
### Inputs
- White background, 1px solid #CBD5E1 border, 10px radius
- Focus: Blue ring (2px solid #2563EB) with outer glow
- Error: Red border + error message below field
- Height: 40px standard, 32px small
### Tables
- Header: #F1F5F9 background, #475569 text, 14px weight 600
- Row hover: #F1F5F9 background
- Cell padding: 16px vertical, 12px horizontal
- Border: Bottom-only using #E2E8F0
### Navigation / Sidebar
- Background: White (#FFFFFF) with right border #E2E8F0
- Active item: #EFF6FF background, #2563EB text, left accent bar (3px)
- Hover item: #F1F5F9 background
- Item height: 40px, 12px radius
- Icons: 20px, inline with label text
### Tags / Badges
- Small radius (6px), medium padding (4px 8px)
- Color-coded backgrounds: blue tint, green tint, amber tint, red tint
- Text matches semantic color (darker shade than background)
### Modals / Dialogs
- 16px radius, generous padding (24px)
- Soft elevated shadow: `0 8px 30px rgba(0,0,0,0.12)`
- Header with title, footer with action buttons
## 5. Layout Principles
### Spacing System
- Base unit: 4px
- Scale: 4px, 8px, 12px, 16px, 20px, 24px, 32px, 40px, 48px, 64px
- Content padding: 24px standard, 16px compact
### Layout
- Fixed sidebar: 240px wide, collapsible to 72px
- Sticky header: 56px
- Content area: Fluid width with max comfortable reading width
- Standard CRUD table/list views for data management
### Border Radius Scale
- Small (6px): Tags, badges, small elements
- Standard (10px): Buttons, inputs, cards
- Large (12px): Panels, modals, large containers
- Full (50%): Circular avatars, icon buttons
## 6. Depth & Elevation
| Level | Shadow | Use |
|-------|--------|-----|
| Level 0 (Flat) | None | Page background, sidebar |
| Level 1 (Subtle) | `0 1px 2px rgba(0,0,0,0.05)` | Cards, form sections |
| Level 2 (Default) | `0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)` | Elevated cards, dropdowns |
| Level 3 (Elevated) | `0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.05)` | Hover states, active cards |
| Level 4 (Modal) | `0 8px 30px rgba(0,0,0,0.12)` | Modals, overlays |
## 7. Interactive States
### Hover
- Background color shift: `#F1F5F9` for neutral, semantic tint for colored elements
- Transition: 200ms ease
- Cursor: `pointer` on clickable elements
### Focus
- 2px solid ring using `#2563EB`
- 2px offset for visibility
- Always visible for keyboard navigation
### Active / Pressed
- Slightly darker shade of the element's color
- Brief scale or shadow change for tactile feedback
### Disabled
- Reduced opacity (0.5)
- No hover effects
- Cursor: `not-allowed`
### Loading
- Spinner or skeleton placeholder
- Disabled interactions during async operations
## 8. Do's and Don'ts
### Do
- Use soft shadows for depth — the Soft UI Evolution identity
- Apply Primary Blue (#2563EB) only for primary actions and active states
- Use Noto Sans SC for consistent Chinese rendering
- Apply 10-12px radius — friendly but professional
- Use semantic color tints for status backgrounds
- Keep tables clean with subtle header differentiation
- Ensure 4.5:1 contrast ratio for all text
### Don't
- Don't use heavy or dramatic shadows — keep depth subtle
- Don't use pure black (#000000) for text — use #0F172A instead
- Don't use pill-shaped buttons — 10px radius is rounded but not pill
- Don't mix warm and cool neutrals — stay in the slate family
- Don't use emojis as icons — use SVG icons (Lucide/Heroicons)
- Don't use decorative-only animations — every animation must serve a purpose
- Don't use colors as the sole means of conveying information
## 9. Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|------|-------|-------------|
| Mobile | <576px | Sidebar collapsed, single column |
| Tablet | 576-768px | Sidebar collapsed, 2-column grid |
| Desktop Small | 768-1024px | Sidebar expanded, responsive grid |
| Desktop | 1024-1440px | Full layout |
| Large Desktop | >1440px | Maximum content width |
### Collapsing Strategy
- Sidebar: 240px → 72px (icon only) on mobile/tablet
- Tables: Horizontal scroll on narrow screens
- Forms: Single column on mobile, multi-column on desktop
- Cards: Full-width stack → grid layout
## 10. ERP Platform Adaptations
### Layout
- Fixed sidebar navigation (240px wide, collapsible to 72px)
- Sticky header (56px) with search, notifications, user menu
- Content area uses CSS Grid for responsive multi-column dashboards
- Standard CRUD table/list views replace masonry grid for data management
### Color Adaptations
- Primary Blue (#2563EB) for primary actions (Save, Create, Submit)
- Success Green (#059669) for positive states (Approved, Completed, Paid)
- Warning Amber (#D97706) for pending states (Pending Review, Awaiting)
- Error Red (#DC2626) for destructive/error states (Rejected, Failed, Overdue)
- Info Blue (#0284C7) for informational elements (Tips, Help, Documentation)
### Component Adaptations
- **Tables**: Slate header bg (#F1F5F9), generous cell padding, subtle hover (#F1F5F9)
- **Forms**: 10px radius inputs, slate borders (#CBD5E1), generous spacing
- **Cards**: 12px radius, soft shadow, white background
- **Sidebar**: Blue active states (#EFF6FF bg), slate hover, 12px radius items
- **Tags/Badges**: 6px radius, semantic color tints (blue/green/amber/red)
- **Modals**: 16px radius, elevated shadow, generous padding
### Dark Mode
- Background: #0F172A (deep navy)
- Container: #1E293B (dark slate)
- Elevated: #334155 (medium slate)
- Border: #334155
- Active accent: #3B82F6 (lighter blue for dark backgrounds)
- Text: #F8FAFC primary, #94A3B8 secondary
## 11. Agent Prompt Guide
### Quick Color Reference
- Brand: Primary Blue (#2563EB)
- Background: Cool Off-White (#F8FAFC)
- Text: Deep Navy (#0F172A)
- Secondary text: Slate (#475569)
- Border: Light Slate (#E2E8F0)
- Success: Green (#059669)
- Warning: Amber (#D97706)
- Error: Red (#DC2626)
- Focus: Blue (#2563EB)
### Example Component Prompts
- "Create a card: white background, 12px radius, soft shadow (0 1px 3px rgba(0,0,0,0.06)). Title in 16px weight 600 #0F172A. Body in 14px #475569."
- "Design a primary button: #2563EB background, white text, 10px radius, 8px 16px padding. Hover: #1D4ED8. Focus: 2px solid #2563EB ring."
- "Build a table: header #F1F5F9 background, #475569 text 14px weight 600. Row hover #F1F5F9. Cell padding 16px 12px. Border bottom #E2E8F0."
- "Create a sidebar: white background, right border #E2E8F0. Active item: #EFF6FF background, #2563EB text, 3px left accent bar. Hover: #F1F5F9."
- "Design a modal: 16px radius, shadow 0 8px 30px rgba(0,0,0,0.12). Title 20px weight 600. Footer with primary blue CTA."
### Iteration Guide
1. Soft shadows everywhere — subtle depth is the identity
2. Primary Blue for CTAs and active states — trustworthy, not overwhelming
3. 10px radius on buttons/inputs, 12px on cards — friendly but professional
4. Noto Sans SC is the primary font — Chinese-first, readable at all sizes
5. Slate neutrals — never pure black or pure gray, always with blue undertone
6. Semantic tints for status — green/amber/red backgrounds with matching text

View File

@@ -31,44 +31,44 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
const themeConfig = {
token: {
colorPrimary: '#4F46E5',
colorPrimary: '#2563eb',
colorSuccess: '#059669',
colorWarning: '#D97706',
colorError: '#DC2626',
colorInfo: '#2563EB',
colorBgLayout: '#F1F5F9',
colorBgContainer: '#FFFFFF',
colorBgElevated: '#FFFFFF',
colorBorder: '#E2E8F0',
colorBorderSecondary: '#F1F5F9',
borderRadius: 8,
colorWarning: '#d97706',
colorError: '#dc2626',
colorInfo: '#0284c7',
colorBgLayout: '#f8fafc',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBorder: '#e2e8f0',
colorBorderSecondary: '#f1f5f9',
borderRadius: 10,
borderRadiusLG: 12,
borderRadiusSM: 6,
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', 'Hiragino Sans GB', 'Helvetica Neue', Helvetica, Arial, sans-serif",
fontFamily: "'Noto Sans SC', -apple-system, system-ui, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif",
fontSize: 14,
fontSizeHeading4: 20,
controlHeight: 36,
controlHeightLG: 40,
controlHeightSM: 28,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06)',
boxShadowSecondary: '0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.07)',
controlHeight: 40,
controlHeightLG: 44,
controlHeightSM: 32,
boxShadow: 'none',
boxShadowSecondary: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
},
components: {
Button: {
primaryShadow: '0 1px 2px 0 rgba(79, 70, 229, 0.3)',
primaryShadow: 'none',
fontWeight: 500,
},
Card: {
paddingLG: 20,
},
Table: {
headerBg: '#F8FAFC',
headerBg: '#f1f5f9',
headerColor: '#475569',
rowHoverBg: '#F5F3FF',
rowHoverBg: '#f1f5f9',
fontSize: 14,
},
Menu: {
itemBorderRadius: 8,
itemBorderRadius: 10,
itemMarginInline: 8,
itemHeight: 40,
},
@@ -85,20 +85,20 @@ const darkThemeConfig = {
...themeConfig,
token: {
...themeConfig.token,
colorBgLayout: '#0B0F1A',
colorBgContainer: '#111827',
colorBgElevated: '#1E293B',
colorBorder: '#1E293B',
colorBorderSecondary: '#1E293B',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.3)',
boxShadowSecondary: '0 4px 6px -1px rgba(0, 0, 0, 0.4)',
colorBgLayout: '#0f172a',
colorBgContainer: '#1e293b',
colorBgElevated: '#334155',
colorBorder: '#334155',
colorBorderSecondary: 'rgba(255, 255, 255, 0.06)',
boxShadow: 'none',
boxShadowSecondary: '0 2px 8px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.2)',
},
components: {
...themeConfig.components,
Table: {
headerBg: '#1E293B',
headerColor: '#94A3B8',
rowHoverBg: '#1E293B',
headerBg: '#1e293b',
headerColor: '#94a3b8',
rowHoverBg: '#1e293b',
},
},
};

View File

@@ -199,7 +199,8 @@ export type PluginPageSchema =
};
export interface DashboardWidget {
type: 'stat_card' | 'bar_chart' | 'pie_chart' | 'funnel_chart' | 'line_chart';
type: 'stat_card' | 'bar_chart' | 'pie_chart' | 'funnel_chart' | 'line_chart'
| 'stat_cards' | 'action_list' | 'funnel' | 'card_list';
entity: string;
title: string;
icon?: string;
@@ -207,6 +208,44 @@ export interface DashboardWidget {
dimension_field?: string;
dimension_order?: string[];
metric?: string;
// stat_cards
cards?: StatCardDef[];
// action_list
max_items?: number;
queries?: ActionQueryDef[];
// funnel
lane_field?: string;
value_field?: string;
lane_order?: string[];
// card_list
filter?: string;
title_field?: string;
subtitle_field?: string;
tags?: string[];
label?: string;
label_field?: string;
action?: string;
sort?: string;
}
export interface StatCardDef {
entity: string;
aggregate?: string;
field?: string;
filter?: string;
label: string;
icon?: string;
color?: string;
}
export interface ActionQueryDef {
entity: string;
filter?: string;
sort?: string;
label_field: string;
subtitle_field?: string;
action: string;
icon?: string;
}
export type PluginSectionSchema =

View File

@@ -50,7 +50,7 @@ export default function NotificationPanel() {
<Button
type="text"
size="small"
style={{ fontSize: 12, color: '#4F46E5' }}
style={{ fontSize: 12, color: '#2563eb' }}
onClick={() => navigate('/messages')}
>
@@ -76,7 +76,7 @@ export default function NotificationPanel() {
cursor: 'pointer',
transition: 'background 0.15s ease',
border: 'none',
background: !item.is_read ? (isDark ? '#1E293B' : '#F5F3FF') : 'transparent',
background: !item.is_read ? (isDark ? '#0f172a' : '#eff6ff') : 'transparent',
}}
onClick={() => {
if (!item.is_read) {
@@ -85,7 +85,7 @@ export default function NotificationPanel() {
}}
onMouseEnter={(e) => {
if (item.is_read) {
e.currentTarget.style.background = isDark ? '#1E293B' : '#F8FAFC';
e.currentTarget.style.background = isDark ? '#0f172a' : '#f1f5f9';
}
}}
onMouseLeave={(e) => {
@@ -109,7 +109,7 @@ export default function NotificationPanel() {
width: 6,
height: 6,
borderRadius: '50%',
background: '#4F46E5',
background: '#2563eb',
flexShrink: 0,
}} />
)}
@@ -132,12 +132,12 @@ export default function NotificationPanel() {
textAlign: 'center',
paddingTop: 8,
marginTop: 4,
borderTop: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
borderTop: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}}>
<Button
type="text"
onClick={() => navigate('/messages')}
style={{ fontSize: 13, color: '#4F46E5', fontWeight: 500 }}
style={{ fontSize: 13, color: '#2563eb', fontWeight: 500 }}
>
</Button>
@@ -166,7 +166,7 @@ export default function NotificationPanel() {
position: 'relative',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = isDark ? '#1E293B' : '#F1F5F9';
e.currentTarget.style.background = isDark ? '#0f172a' : '#f8fafc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
@@ -175,7 +175,7 @@ export default function NotificationPanel() {
<Badge count={unreadCount} size="small" offset={[4, -4]}>
<BellOutlined style={{
fontSize: 16,
color: isDark ? '#94A3B8' : '#64748B',
color: isDark ? '#94a3b8' : '#475569',
}} />
</Badge>
</div>

View File

@@ -2,65 +2,66 @@
/* ====================================================================
* ERP Platform — Design System Tokens & Global Styles
* Inspired by Linear, Feishu, SAP Fiori modern design language
* Soft UI Evolution: Professional, warm, accessible for all industries
* Generated by UI UX Pro Max
* ==================================================================== */
/* --- Design Tokens (CSS Custom Properties) --- */
:root {
/* Primary Palette */
--erp-primary: #4F46E5;
--erp-primary-hover: #4338CA;
--erp-primary-active: #3730A3;
--erp-primary-light: #EEF2FF;
--erp-primary-light-hover: #E0E7FF;
--erp-primary-bg-subtle: #F5F3FF;
/* Primary Palette — Trust Blue */
--erp-primary: #2563eb;
--erp-primary-hover: #1d4ed8;
--erp-primary-active: #1e40af;
--erp-primary-light: #eff6ff;
--erp-primary-light-hover: #dbeafe;
--erp-primary-bg-subtle: #eff6ff;
/* Semantic Colors */
/* Semantic Colors — Professional slate tones */
--erp-success: #059669;
--erp-success-bg: #ECFDF5;
--erp-warning: #D97706;
--erp-warning-bg: #FFFBEB;
--erp-error: #DC2626;
--erp-error-bg: #FEF2F2;
--erp-info: #2563EB;
--erp-info-bg: #EFF6FF;
--erp-success-bg: #ecfdf5;
--erp-warning: #d97706;
--erp-warning-bg: #fffbeb;
--erp-error: #dc2626;
--erp-error-bg: #fef2f2;
--erp-info: #0284c7;
--erp-info-bg: #f0f9ff;
/* Neutral Palette */
--erp-bg-page: #F1F5F9;
--erp-bg-container: #FFFFFF;
--erp-bg-elevated: #FFFFFF;
--erp-bg-spotlight: #F8FAFC;
--erp-bg-sidebar: #0F172A;
--erp-bg-sidebar-hover: #1E293B;
--erp-bg-sidebar-active: rgba(79, 70, 229, 0.15);
/* Neutral Palette — Slate neutrals with blue undertones */
--erp-bg-page: #f8fafc;
--erp-bg-container: #ffffff;
--erp-bg-elevated: #ffffff;
--erp-bg-spotlight: #f1f5f9;
--erp-bg-sidebar: #ffffff;
--erp-bg-sidebar-hover: #f1f5f9;
--erp-bg-sidebar-active: #eff6ff;
/* Text Colors */
--erp-text-primary: #0F172A;
/* Text Colors — Deep navy */
--erp-text-primary: #0f172a;
--erp-text-secondary: #475569;
--erp-text-tertiary: #94A3B8;
--erp-text-inverse: #F8FAFC;
--erp-text-sidebar: #CBD5E1;
--erp-text-sidebar-active: #FFFFFF;
--erp-text-tertiary: #94a3b8;
--erp-text-inverse: #ffffff;
--erp-text-sidebar: #475569;
--erp-text-sidebar-active: #2563eb;
/* Border Colors */
--erp-border: #E2E8F0;
--erp-border-light: #F1F5F9;
--erp-border-dark: #334155;
/* Border Colors — Slate borders */
--erp-border: #e2e8f0;
--erp-border-light: #f1f5f9;
--erp-border-dark: #cbd5e1;
/* Shadows */
--erp-shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
--erp-shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06);
--erp-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.07);
--erp-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.08);
--erp-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.08);
/* Shadows — Soft UI Evolution: subtle, layered depth */
--erp-shadow-xs: 0 1px 2px rgba(0,0,0,0.05);
--erp-shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--erp-shadow-md: 0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.05);
--erp-shadow-lg: 0 8px 30px rgba(0,0,0,0.12);
--erp-shadow-xl: 0 12px 40px rgba(0,0,0,0.15);
/* Radius */
/* Radius — Soft UI: friendly but professional */
--erp-radius-sm: 6px;
--erp-radius-md: 8px;
--erp-radius-md: 10px;
--erp-radius-lg: 12px;
--erp-radius-xl: 16px;
/* Spacing */
/* Spacing — 4px base unit */
--erp-space-xs: 4px;
--erp-space-sm: 8px;
--erp-space-md: 16px;
@@ -68,11 +69,10 @@
--erp-space-xl: 32px;
--erp-space-2xl: 48px;
/* Typography */
--erp-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC',
'Microsoft YaHei', 'Hiragino Sans GB', 'Helvetica Neue', Helvetica, Arial, sans-serif;
--erp-font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
/* Typography — Noto Sans SC for Chinese-first ERP */
--erp-font-family: 'Noto Sans SC', -apple-system, system-ui, 'Segoe UI', Roboto,
'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif;
--erp-font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace;
--erp-font-size-xs: 12px;
--erp-font-size-sm: 13px;
--erp-font-size-base: 14px;
@@ -88,8 +88,8 @@
/* Trend Colors */
--erp-trend-up: #059669;
--erp-trend-down: #DC2626;
--erp-trend-neutral: #64748B;
--erp-trend-down: #dc2626;
--erp-trend-neutral: #475569;
/* Line Height */
--erp-line-height-tight: 1.25;
@@ -104,34 +104,46 @@
/* --- Dark Mode Tokens --- */
[data-theme='dark'] {
--erp-bg-page: #0B0F1A;
--erp-bg-container: #111827;
--erp-bg-elevated: #1E293B;
--erp-bg-spotlight: #1E293B;
--erp-bg-sidebar: #070B14;
--erp-bg-sidebar-hover: #111827;
--erp-primary-light: rgba(37, 99, 235, 0.15);
--erp-primary-light-hover: rgba(37, 99, 235, 0.22);
--erp-primary-bg-subtle: rgba(37, 99, 235, 0.1);
--erp-text-primary: #F1F5F9;
--erp-text-secondary: #94A3B8;
--erp-text-tertiary: #64748B;
--erp-bg-page: #0f172a;
--erp-bg-container: #1e293b;
--erp-bg-elevated: #334155;
--erp-bg-spotlight: #1e293b;
--erp-bg-sidebar: #0f172a;
--erp-bg-sidebar-hover: #1e293b;
--erp-border: #1E293B;
--erp-border-light: #1E293B;
--erp-text-primary: rgba(255, 255, 255, 0.95);
--erp-text-secondary: #94a3b8;
--erp-text-tertiary: #64748b;
--erp-text-sidebar: #94a3b8;
--erp-text-sidebar-active: #60a5fa;
--erp-shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--erp-shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px -1px rgba(0, 0, 0, 0.3);
--erp-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--erp-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
--erp-border: #334155;
--erp-border-light: rgba(255, 255, 255, 0.06);
--erp-border-dark: rgba(255, 255, 255, 0.12);
--erp-trend-up: #34D399;
--erp-trend-down: #F87171;
--erp-trend-neutral: #94A3B8;
--erp-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--erp-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
--erp-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3), 0 2px 6px rgba(0, 0, 0, 0.2);
--erp-shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.4);
--erp-trend-up: #34d399;
--erp-trend-down: #f87171;
--erp-trend-neutral: #94a3b8;
--erp-success-bg: rgba(5, 150, 105, 0.15);
--erp-warning-bg: rgba(217, 119, 6, 0.15);
--erp-error-bg: rgba(220, 38, 38, 0.15);
--erp-info-bg: rgba(2, 132, 199, 0.15);
}
[data-theme='dark'] .erp-stat-card-trend-up { color: #34D399; }
[data-theme='dark'] .erp-stat-card-trend-down { color: #FCA5A5; }
[data-theme='dark'] .erp-stat-card-trend-neutral { color: #94A3B8; }
[data-theme='dark'] .erp-stat-card-trend-label { color: #94A3B8; }
[data-theme='dark'] .erp-stat-card-trend-up { color: #34d399; }
[data-theme='dark'] .erp-stat-card-trend-down { color: #f87171; }
[data-theme='dark'] .erp-stat-card-trend-neutral { color: #94a3b8; }
[data-theme='dark'] .erp-stat-card-trend-label { color: #94a3b8; }
/* --- Global Reset & Base --- */
body {
@@ -170,20 +182,20 @@ body {
/* --- Selection --- */
::selection {
background-color: var(--erp-primary-light);
color: var(--erp-primary);
background-color: rgba(37, 99, 235, 0.15);
color: var(--erp-text-primary);
}
/* ====================================================================
* Component Overrides — Ant Design Enhancement
* ==================================================================== */
/* --- Card --- */
/* --- Card — Soft shadow, clean border --- */
.ant-card {
border-radius: var(--erp-radius-lg) !important;
border: 1px solid var(--erp-border-light) !important;
border: 1px solid var(--erp-border) !important;
box-shadow: var(--erp-shadow-xs) !important;
transition: box-shadow var(--erp-transition-base), transform var(--erp-transition-base) !important;
transition: box-shadow var(--erp-transition-base) !important;
}
.ant-card:hover {
@@ -209,15 +221,14 @@ body {
/* --- Statistic Cards --- */
.stat-card {
border-radius: var(--erp-radius-lg) !important;
border: none !important;
border: 1px solid var(--erp-border) !important;
overflow: hidden;
position: relative;
transition: all var(--erp-transition-base) !important;
transition: box-shadow var(--erp-transition-base) !important;
}
.stat-card:hover {
transform: translateY(-2px) !important;
box-shadow: var(--erp-shadow-md) !important;
box-shadow: var(--erp-shadow-sm) !important;
}
.stat-card .ant-statistic-title {
@@ -251,7 +262,7 @@ body {
}
.ant-table-tbody > tr:hover > td {
background: var(--erp-primary-bg-subtle) !important;
background: var(--erp-bg-spotlight) !important;
}
.ant-table-tbody > tr > td {
@@ -263,13 +274,12 @@ body {
.ant-btn-primary {
border-radius: var(--erp-radius-md) !important;
font-weight: 500 !important;
box-shadow: 0 1px 2px 0 rgba(79, 70, 229, 0.3) !important;
box-shadow: none !important;
transition: all var(--erp-transition-fast) !important;
}
.ant-btn-primary:hover {
box-shadow: 0 2px 4px 0 rgba(79, 70, 229, 0.4) !important;
transform: translateY(-1px);
opacity: 0.9;
}
.ant-btn-default {
@@ -297,7 +307,7 @@ body {
.ant-select-focused .ant-select-selector,
.ant-picker-focused {
border-color: var(--erp-primary) !important;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.12) !important;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12) !important;
}
/* --- Modal --- */
@@ -426,12 +436,12 @@ body {
border-radius: var(--erp-radius-lg) var(--erp-radius-lg) 0 0;
}
.erp-gradient-card.indigo::before { background: linear-gradient(90deg, #4F46E5, #818CF8); }
.erp-gradient-card.emerald::before { background: linear-gradient(90deg, #059669, #34D399); }
.erp-gradient-card.amber::before { background: linear-gradient(90deg, #D97706, #FBBF24); }
.erp-gradient-card.rose::before { background: linear-gradient(90deg, #E11D48, #FB7185); }
.erp-gradient-card.sky::before { background: linear-gradient(90deg, #0284C7, #38BDF8); }
.erp-gradient-card.violet::before { background: linear-gradient(90deg, #7C3AED, #A78BFA); }
.erp-gradient-card.indigo::before { background: linear-gradient(90deg, #2563eb, #60a5fa); }
.erp-gradient-card.emerald::before { background: linear-gradient(90deg, #059669, #34d399); }
.erp-gradient-card.amber::before { background: linear-gradient(90deg, #d97706, #fbbf24); }
.erp-gradient-card.rose::before { background: linear-gradient(90deg, #dc2626, #f87171); }
.erp-gradient-card.sky::before { background: linear-gradient(90deg, #0284c7, #38bdf8); }
.erp-gradient-card.violet::before { background: linear-gradient(90deg, #7c3aed, #a78bfa); }
/* --- Fade-in Animation --- */
@keyframes erp-fade-in {
@@ -465,7 +475,7 @@ body {
*:focus-visible {
outline: 2px solid var(--erp-primary);
outline-offset: 2px;
border-radius: 4px;
border-radius: var(--erp-radius-sm);
}
.erp-sidebar-item:focus-visible {
@@ -481,7 +491,7 @@ body {
background: var(--erp-primary);
color: #fff;
padding: 8px 24px;
border-radius: 0 0 8px 8px;
border-radius: 0 0 var(--erp-radius-md) var(--erp-radius-md);
z-index: 10000;
font-size: 14px;
font-weight: 600;
@@ -529,23 +539,23 @@ body {
* ==================================================================== */
.erp-sidebar-menu .ant-menu-item {
margin: 2px 8px !important;
margin: 1px 8px !important;
border-radius: var(--erp-radius-md) !important;
height: 40px !important;
line-height: 40px !important;
height: 36px !important;
line-height: 36px !important;
}
.erp-sidebar-menu .ant-menu-item-selected {
background: var(--erp-primary) !important;
color: #fff !important;
background: #eff6ff !important;
color: #2563eb !important;
}
.erp-sidebar-menu .ant-menu-item-selected .anticon {
color: #fff !important;
color: #2563eb !important;
}
.erp-sidebar-menu .ant-menu-item:not(.ant-menu-item-selected):hover {
background: var(--erp-bg-sidebar-hover) !important;
background: #f1f5f9 !important;
}
/* Sidebar group label */
@@ -555,17 +565,17 @@ body {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: #94A3B8;
color: #94a3b8;
}
/* ====================================================================
* MainLayout — CSS classes replacing inline styles
* ==================================================================== */
/* Sider */
/* Sider — White sidebar, Soft UI style */
.erp-sider-dark {
background: #0F172A !important;
border-right: none !important;
background: #ffffff !important;
border-right: 1px solid #e2e8f0 !important;
position: fixed !important;
left: 0;
top: 0;
@@ -575,7 +585,8 @@ body {
}
[data-theme='dark'] .erp-sider-dark {
background: #070B14 !important;
background: #0f172a !important;
border-right: 1px solid #334155 !important;
}
/* Logo */
@@ -584,48 +595,56 @@ body {
display: flex;
align-items: center;
padding: 0 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
border-bottom: 1px solid #e2e8f0;
cursor: pointer;
}
[data-theme='dark'] .erp-sidebar-logo {
border-bottom: 1px solid #334155;
}
.ant-layout-sider-collapsed .erp-sidebar-logo {
justify-content: center;
padding: 0;
}
.erp-sidebar-logo-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, #4F46E5, #818CF8);
width: 28px;
height: 28px;
border-radius: var(--erp-radius-sm);
background: #2563eb;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 14px;
font-size: 13px;
font-weight: 800;
color: #fff;
}
.erp-sidebar-logo-text {
margin-left: 12px;
color: #F8FAFC;
font-size: 16px;
margin-left: 10px;
color: #0f172a;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.3px;
white-space: nowrap;
}
[data-theme='dark'] .erp-sidebar-logo-text {
color: rgba(255, 255, 255, 0.95);
}
/* Sidebar menu item */
.erp-sidebar-item {
display: flex;
align-items: center;
height: 40px;
margin: 2px 8px;
padding: 0 16px;
border-radius: 8px;
height: 36px;
margin: 1px 8px;
padding: 0 12px;
border-radius: var(--erp-radius-md);
cursor: pointer;
color: #94A3B8;
color: #475569;
font-size: 14px;
font-weight: 400;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
@@ -638,14 +657,28 @@ body {
}
.erp-sidebar-item:hover:not(.erp-sidebar-item-active) {
background: rgba(255, 255, 255, 0.06);
color: #E2E8F0;
background: #f1f5f9;
color: #0f172a;
}
[data-theme='dark'] .erp-sidebar-item {
color: #94a3b8;
}
[data-theme='dark'] .erp-sidebar-item:hover:not(.erp-sidebar-item-active) {
background: #1e293b;
color: rgba(255, 255, 255, 0.95);
}
.erp-sidebar-item-active {
background: linear-gradient(135deg, #4F46E5, #6366F1);
color: #fff;
font-weight: 600;
background: #eff6ff;
color: #2563eb;
font-weight: 500;
}
[data-theme='dark'] .erp-sidebar-item-active {
background: rgba(37, 99, 235, 0.15);
color: #60a5fa;
}
.erp-sidebar-item-icon {
@@ -665,10 +698,10 @@ body {
height: 32px;
margin: 6px 8px 2px 8px;
padding: 0 12px;
border-radius: 6px;
border-radius: var(--erp-radius-md);
cursor: pointer;
color: #94A3B8;
font-size: 12px;
color: #94a3b8;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -677,12 +710,25 @@ body {
}
.erp-sidebar-submenu-title:hover {
background: rgba(255, 255, 255, 0.06);
color: #E2E8F0;
background: #f1f5f9;
color: #475569;
}
[data-theme='dark'] .erp-sidebar-submenu-title {
color: #64748b;
}
[data-theme='dark'] .erp-sidebar-submenu-title:hover {
background: #1e293b;
color: #94a3b8;
}
.erp-sidebar-submenu-title-active {
color: #A5B4FC;
color: #2563eb;
}
[data-theme='dark'] .erp-sidebar-submenu-title-active {
color: #60a5fa;
}
.erp-sidebar-submenu-arrow {
@@ -707,8 +753,8 @@ body {
transition: margin-left 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.erp-main-layout-light { background: #F1F5F9; }
.erp-main-layout-dark { background: #0B0F1A; }
.erp-main-layout-light { background: #f8fafc; }
.erp-main-layout-dark { background: #0f172a; }
/* Header */
.erp-header {
@@ -724,44 +770,44 @@ body {
}
.erp-header-light {
background: #FFFFFF !important;
border-bottom: 1px solid #F1F5F9;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
background: #ffffff !important;
border-bottom: 1px solid #e2e8f0;
box-shadow: none;
}
.erp-header-dark {
background: #111827 !important;
border-bottom: 1px solid #1E293B;
background: #1e293b !important;
border-bottom: 1px solid #334155;
box-shadow: none;
}
.erp-header-btn {
width: 36px;
height: 36px;
border-radius: 8px;
width: 32px;
height: 32px;
border-radius: var(--erp-radius-md);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
color: #94A3B8;
color: #475569;
will-change: background;
}
.erp-header-light .erp-header-btn { color: #64748B; }
.erp-header-dark .erp-header-btn { color: #94A3B8; }
.erp-header-btn:hover { background: #F1F5F9; }
.erp-header-dark .erp-header-btn:hover { background: #1E293B; }
.erp-header-light .erp-header-btn { color: #475569; }
.erp-header-dark .erp-header-btn { color: #94a3b8; }
.erp-header-btn:hover { background: #f1f5f9; }
.erp-header-dark .erp-header-btn:hover { background: #334155; }
.erp-header-title { font-size: 15px; font-weight: 600; }
.erp-text-light { color: #0F172A; }
.erp-text-dark { color: #F1F5F9; }
.erp-text-light-secondary { color: #334155; }
.erp-text-dark-secondary { color: #E2E8F0; }
.erp-text-light { color: #0f172a; }
.erp-text-dark { color: rgba(255, 255, 255, 0.95); }
.erp-text-light-secondary { color: #475569; }
.erp-text-dark-secondary { color: #94a3b8; }
.erp-header-divider { width: 1px; height: 24px; margin: 0 8px; }
.erp-header-divider-light { background: #E2E8F0; }
.erp-header-divider-dark { background: #1E293B; }
.erp-header-divider-light { background: rgba(0, 0, 0, 0.06); }
.erp-header-divider-dark { background: rgba(255, 255, 255, 0.06); }
/* User avatar */
.erp-header-user {
@@ -770,15 +816,15 @@ body {
gap: 10px;
cursor: pointer;
padding: 4px 8px;
border-radius: 8px;
border-radius: var(--erp-radius-sm);
transition: all 0.15s ease;
}
.erp-header-user:hover { background: #F1F5F9; }
.erp-header-dark .erp-header-user:hover { background: #1E293B; }
.erp-header-user:hover { background: #f1f5f9; }
.erp-header-dark .erp-header-user:hover { background: #334155; }
.erp-user-avatar {
background: linear-gradient(135deg, #4F46E5, #818CF8) !important;
background: #2563eb !important;
font-size: 13px !important;
}
@@ -786,11 +832,11 @@ body {
/* Footer */
.erp-footer { text-align: center; padding: 12px 24px !important; background: transparent !important; font-size: 12px; }
.erp-footer-light { color: #475569; }
.erp-footer-dark { color: #94A3B8; }
.erp-footer-light { color: #94a3b8; }
.erp-footer-dark { color: #64748b; }
/* ====================================================================
* Dashboard — Stat Cards & Quick Actions (replacing inline styles)
* Dashboard — Stat Cards & Quick Actions
* ==================================================================== */
/* Stat Card */
@@ -818,7 +864,7 @@ body {
left: 0;
right: 0;
height: 3px;
background: var(--card-gradient, linear-gradient(135deg, #4F46E5, #6366F1));
background: var(--card-gradient, linear-gradient(135deg, #2563eb, #60a5fa));
}
.erp-stat-card-body {
@@ -849,7 +895,7 @@ body {
width: 48px;
height: 48px;
border-radius: var(--erp-radius-lg);
background: var(--card-icon-bg, rgba(79, 70, 229, 0.12));
background: var(--card-icon-bg, rgba(37, 99, 235, 0.08));
display: flex;
align-items: center;
justify-content: center;
@@ -857,7 +903,7 @@ body {
flex-shrink: 0;
}
/* Section Header (shared by dashboard sections) */
/* Section Header */
.erp-section-header {
display: flex;
align-items: center;
@@ -867,7 +913,7 @@ body {
.erp-section-icon {
font-size: 16px;
color: #4F46E5;
color: #2563eb;
}
.erp-section-title {
@@ -882,7 +928,7 @@ body {
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 10px;
border-radius: var(--erp-radius-lg);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
background: var(--erp-bg-spotlight);
@@ -890,17 +936,17 @@ body {
}
.erp-quick-action:hover {
background: #EEF2FF;
border-color: var(--action-color, #4F46E5);
background: #eff6ff;
border-color: var(--action-color, #2563eb);
}
[data-theme='dark'] .erp-quick-action {
background: #0B0F1A;
background: #0f172a;
}
[data-theme='dark'] .erp-quick-action:hover {
background: #1E293B;
border-color: var(--action-color, #4F46E5);
background: #1e293b;
border-color: var(--action-color, #2563eb);
}
.erp-quick-action-icon {
@@ -910,8 +956,8 @@ body {
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--action-color, #4F46E5) 10%, transparent);
color: var(--action-color, #4F46E5);
background: color-mix(in srgb, var(--action-color, #2563eb) 8%, transparent);
color: var(--action-color, #2563eb);
font-size: 16px;
flex-shrink: 0;
}
@@ -962,12 +1008,12 @@ body {
font-weight: 500;
}
.erp-stat-card-trend-up { color: #047857; }
.erp-stat-card-trend-down { color: #B91C1C; }
.erp-stat-card-trend-neutral { color: #64748B; }
.erp-stat-card-trend-up { color: #059669; }
.erp-stat-card-trend-down { color: #dc2626; }
.erp-stat-card-trend-neutral { color: #475569; }
.erp-stat-card-trend-label {
color: #64748B;
color: #475569;
font-weight: 400;
}
@@ -1000,8 +1046,8 @@ body {
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--action-color, #4F46E5) 10%, transparent);
color: var(--action-color, #4F46E5);
background: color-mix(in srgb, var(--action-color, #2563eb) 8%, transparent);
color: var(--action-color, #2563eb);
font-size: 18px;
flex-shrink: 0;
transition: transform 0.15s ease;
@@ -1029,7 +1075,7 @@ body {
padding: 12px 16px;
border-radius: var(--erp-radius-md);
background: var(--erp-bg-spotlight);
border-left: 3px solid var(--task-color, #4F46E5);
border-left: 3px solid var(--task-color, #2563eb);
cursor: pointer;
transition: all 0.15s ease;
}
@@ -1042,12 +1088,12 @@ body {
.erp-task-item-icon {
width: 32px;
height: 32px;
border-radius: 8px;
border-radius: var(--erp-radius-sm);
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--task-color, #4F46E5) 12%, transparent);
color: var(--task-color, #4F46E5);
background: color-mix(in srgb, var(--task-color, #2563eb) 8%, transparent);
color: var(--task-color, #2563eb);
font-size: 14px;
flex-shrink: 0;
}
@@ -1069,25 +1115,25 @@ body {
gap: 12px;
margin-top: 2px;
font-size: var(--erp-font-size-xs);
color: #64748B;
color: #94a3b8;
}
.erp-task-priority {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: 10px;
border-radius: var(--erp-radius-sm);
font-size: 11px;
font-weight: 600;
}
.erp-task-priority-high { background: #FEF2F2; color: #B91C1C; }
.erp-task-priority-medium { background: #FFFBEB; color: #92400E; }
.erp-task-priority-low { background: #ECFDF5; color: #047857; }
.erp-task-priority-high { background: #fef2f2; color: #dc2626; }
.erp-task-priority-medium { background: #fffbeb; color: #d97706; }
.erp-task-priority-low { background: #ecfdf5; color: #059669; }
[data-theme='dark'] .erp-task-priority-high { background: rgba(185, 28, 28, 0.15); color: #FCA5A5; }
[data-theme='dark'] .erp-task-priority-medium { background: rgba(146, 64, 14, 0.15); color: #FCD34D; }
[data-theme='dark'] .erp-task-priority-low { background: rgba(4, 120, 87, 0.15); color: #6EE7B7; }
[data-theme='dark'] .erp-task-priority-high { background: rgba(220, 38, 38, 0.15); color: #f87171; }
[data-theme='dark'] .erp-task-priority-medium { background: rgba(217, 119, 6, 0.15); color: #fbbf24; }
[data-theme='dark'] .erp-task-priority-low { background: rgba(5, 150, 105, 0.15); color: #34d399; }
/* Activity Timeline */
.erp-activity-list {
@@ -1143,12 +1189,12 @@ body {
.erp-activity-time {
font-size: 11px;
color: #64748B;
color: #94a3b8;
margin-top: 2px;
}
[data-theme='dark'] .erp-activity-time {
color: #94A3B8;
color: #64748b;
}
/* Empty State */

View File

@@ -167,7 +167,7 @@ export default function Home() {
title: '用户总数',
value: stats.userCount,
icon: <UserOutlined />,
gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)',
gradient: 'linear-gradient(135deg, #2563eb, #60a5fa)',
iconBg: 'rgba(79, 70, 229, 0.12)',
delay: 'erp-fade-in erp-fade-in-delay-1',
trend: { value: '+2', direction: 'up', label: '较上周' },
@@ -191,7 +191,7 @@ export default function Home() {
title: '流程实例',
value: stats.processInstanceCount,
icon: <FileTextOutlined />,
gradient: 'linear-gradient(135deg, #D97706, #F59E0B)',
gradient: 'linear-gradient(135deg, #d97706, #F59E0B)',
iconBg: 'rgba(217, 119, 6, 0.12)',
delay: 'erp-fade-in erp-fade-in-delay-3',
trend: { value: '0', direction: 'neutral', label: '较昨日' },
@@ -213,17 +213,17 @@ export default function Home() {
];
const quickActions = [
{ icon: <UserOutlined />, label: '用户管理', path: '/users', color: '#4F46E5' },
{ icon: <UserOutlined />, label: '用户管理', path: '/users', color: '#2563eb' },
{ icon: <SafetyCertificateOutlined />, label: '权限管理', path: '/roles', color: '#059669' },
{ icon: <ApartmentOutlined />, label: '组织架构', path: '/organizations', color: '#D97706' },
{ icon: <ApartmentOutlined />, label: '组织架构', path: '/organizations', color: '#d97706' },
{ icon: <PartitionOutlined />, label: '工作流', path: '/workflow', color: '#7C3AED' },
{ icon: <BellOutlined />, label: '消息中心', path: '/messages', color: '#E11D48' },
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings', color: '#64748B' },
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings', color: '#475569' },
];
const pendingTasks: TaskItem[] = [
{ id: '1', title: '审核新用户注册申请', priority: 'high', assignee: '系统', dueText: '待处理', color: '#DC2626', icon: <UserOutlined />, path: '/users' },
{ id: '2', title: '配置工作流审批节点', priority: 'medium', assignee: '管理员', dueText: '进行中', color: '#D97706', icon: <PartitionOutlined />, path: '/workflow' },
{ id: '1', title: '审核新用户注册申请', priority: 'high', assignee: '系统', dueText: '待处理', color: '#dc2626', icon: <UserOutlined />, path: '/users' },
{ id: '2', title: '配置工作流审批节点', priority: 'medium', assignee: '管理员', dueText: '进行中', color: '#d97706', icon: <PartitionOutlined />, path: '/workflow' },
{ id: '3', title: '更新角色权限策略', priority: 'low', assignee: '管理员', dueText: '计划中', color: '#059669', icon: <SafetyCertificateOutlined />, path: '/roles' },
];
@@ -243,13 +243,13 @@ export default function Home() {
<h2 style={{
fontSize: 24,
fontWeight: 700,
color: isDark ? '#F1F5F9' : '#0F172A',
color: isDark ? '#f8fafc' : 'rgba(0,0,0,0.95)',
margin: '0 0 4px',
letterSpacing: '-0.5px',
}}>
</h2>
<p style={{ fontSize: 14, color: isDark ? '#94A3B8' : '#475569', margin: 0 }}>
<p style={{ fontSize: 14, color: isDark ? '#94a3b8' : '#475569', margin: 0 }}>
</p>
</div>
@@ -308,12 +308,12 @@ export default function Home() {
<Col xs={24} lg={14}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-2">
<div className="erp-section-header">
<CheckCircleOutlined className="erp-section-icon" style={{ color: '#E11D48' }} />
<CheckCircleOutlined className="erp-section-icon" style={{ color: '#2563eb' }} />
<span className="erp-section-title"></span>
<span style={{
marginLeft: 'auto',
fontSize: 12,
color: isDark ? '#94A3B8' : '#64748B',
color: isDark ? '#94a3b8' : '#475569',
}}>
{pendingTasks.length}
</span>
@@ -351,7 +351,7 @@ export default function Home() {
<Col xs={24} lg={10}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-3" style={{ height: '100%' }}>
<div className="erp-section-header">
<ClockCircleOutlined className="erp-section-icon" style={{ color: '#6366F1' }} />
<ClockCircleOutlined className="erp-section-icon" style={{ color: '#60a5fa' }} />
<span className="erp-section-title"></span>
</div>
<div className="erp-activity-list">
@@ -400,7 +400,7 @@ export default function Home() {
<Col xs={24} lg={8}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-4" style={{ height: '100%' }}>
<div className="erp-section-header">
<ClockCircleOutlined className="erp-section-icon" style={{ color: '#6366F1' }} />
<ClockCircleOutlined className="erp-section-icon" style={{ color: '#60a5fa' }} />
<span className="erp-section-title"></span>
</div>
<div className="erp-system-info-list">

View File

@@ -30,7 +30,7 @@ export default function Login() {
<div
style={{
flex: 1,
background: 'linear-gradient(135deg, #312E81 0%, #4F46E5 50%, #6366F1 100%)',
background: 'linear-gradient(135deg, #312E81 0%, #2563eb 50%, #60a5fa 100%)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
@@ -151,7 +151,7 @@ export default function Login() {
<h2 style={{ marginBottom: 4, fontWeight: 700, fontSize: 24 }}>
</h2>
<p style={{ fontSize: 14, color: '#64748B' }}>
<p style={{ fontSize: 14, color: '#475569' }}>
</p>
@@ -163,7 +163,7 @@ export default function Login() {
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined style={{ color: '#94A3B8' }} />}
prefix={<UserOutlined style={{ color: '#94a3b8' }} />}
placeholder="用户名"
style={{ height: 44, borderRadius: 10 }}
/>
@@ -173,7 +173,7 @@ export default function Login() {
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: '#94A3B8' }} />}
prefix={<LockOutlined style={{ color: '#94a3b8' }} />}
placeholder="密码"
style={{ height: 44, borderRadius: 10 }}
/>
@@ -197,7 +197,7 @@ export default function Login() {
</Form>
<div style={{ marginTop: 32, textAlign: 'center' }}>
<p style={{ fontSize: 12, color: '#64748B', margin: 0 }}>
<p style={{ fontSize: 12, color: '#475569', margin: 0 }}>
ERP Platform v0.1.0 · Powered by Rust + React
</p>
</div>

View File

@@ -44,7 +44,7 @@ export default function Organizations() {
const cardStyle = {
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
};
// --- Org tree state ---
@@ -264,9 +264,9 @@ export default function Organizations() {
{item.name}{' '}
{item.code && <Tag style={{
marginLeft: 4,
background: isDark ? '#1E293B' : '#EEF2FF',
background: isDark ? '#0f172a' : '#eff6ff',
border: 'none',
color: '#4F46E5',
color: '#2563eb',
fontSize: 11,
}}>{item.code}</Tag>}
</span>
@@ -282,7 +282,7 @@ export default function Organizations() {
{item.name}{' '}
{item.code && <Tag style={{
marginLeft: 4,
background: isDark ? '#1E293B' : '#ECFDF5',
background: isDark ? '#0f172a' : '#ECFDF5',
border: 'none',
color: '#059669',
fontSize: 11,
@@ -343,7 +343,7 @@ export default function Organizations() {
<div className="erp-page-header">
<div>
<h4>
<ApartmentOutlined style={{ marginRight: 8, color: '#4F46E5' }} />
<ApartmentOutlined style={{ marginRight: 8, color: '#2563eb' }} />
</h4>
<div className="erp-page-subtitle"></div>
@@ -356,7 +356,7 @@ export default function Organizations() {
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{
padding: '14px 20px',
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
@@ -418,7 +418,7 @@ export default function Organizations() {
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{
padding: '14px 20px',
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
@@ -471,7 +471,7 @@ export default function Organizations() {
<div style={{ flex: 1, ...cardStyle, overflow: 'hidden' }}>
<div style={{
padding: '14px 20px',
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',

View File

@@ -41,11 +41,11 @@ import {
import PluginSettingsForm from '../components/PluginSettingsForm';
const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = {
uploaded: { color: '#64748B', label: '已上传' },
uploaded: { color: '#475569', label: '已上传' },
installed: { color: '#2563EB', label: '已安装' },
enabled: { color: '#059669', label: '已启用' },
running: { color: '#059669', label: '运行中' },
disabled: { color: '#DC2626', label: '已禁用' },
disabled: { color: '#dc2626', label: '已禁用' },
uninstalled: { color: '#9333EA', label: '已卸载' },
};
@@ -215,7 +215,7 @@ export default function PluginAdmin() {
key: 'status',
width: 100,
render: (status: PluginStatus) => {
const cfg = STATUS_CONFIG[status] || { color: '#64748B', label: status };
const cfg = STATUS_CONFIG[status] || { color: '#475569', label: status };
return <Tag color={cfg.color}>{cfg.label}</Tag>;
},
},

View File

@@ -773,9 +773,14 @@ export default function PluginCRUDPage({
name={field.name}
label={field.display_name || field.name}
rules={
field.required
? [{ required: true, message: `请输入${field.display_name || field.name}` }]
: []
[
...(field.required
? [{ required: true, message: `请输入${field.display_name || field.name}` }]
: []),
...(field.validation?.pattern
? [{ pattern: new RegExp(field.validation.pattern), message: field.validation.message || '格式不正确' }]
: []),
]
}
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
>

View File

@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Row, Col, Empty, Select, theme } from 'antd';
import { DashboardOutlined } from '@ant-design/icons';
import { countPluginData, aggregatePluginData } from '../api/pluginData';
import { countPluginData, aggregatePluginData, listPluginData } from '../api/pluginData';
import {
getPluginSchema,
type PluginEntitySchema,
@@ -134,10 +134,65 @@ export function PluginDashboardPage() {
const results = await Promise.all(
widgets.map(async (widget) => {
try {
// 旧类型
if (widget.type === 'stat_card') {
const count = await countPluginData(pluginId!, widget.entity);
return { widget, data: [], count };
}
// stat_cards — 多个统计卡片
if (widget.type === 'stat_cards' && widget.cards) {
const cardResults = await Promise.all(
widget.cards.map(async (card) => {
try {
const count = await countPluginData(pluginId!, card.entity, {
filter: card.filter ? JSON.parse(card.filter) : undefined,
});
return { card, value: count };
} catch {
return { card, value: 0 };
}
}),
);
return { widget, data: [], statCards: cardResults };
}
// action_list — 待办列表
if (widget.type === 'action_list' && widget.queries) {
const actionResults = await Promise.all(
widget.queries.map(async (query) => {
try {
const filterObj = query.filter ? JSON.parse(query.filter) : undefined;
const sortParts = query.sort?.split(' ') ?? [];
const result = await listPluginData(pluginId!, query.entity, 1, widget.max_items ?? 10, {
filter: filterObj,
sort_by: sortParts[0] || undefined,
sort_order: (sortParts[1] as 'asc' | 'desc') || undefined,
});
return { query, records: result.data };
} catch {
return { query, records: [] };
}
}),
);
return { widget, data: [], actionItems: actionResults };
}
// funnel — 阶段漏斗
if (widget.type === 'funnel' && widget.lane_field) {
const data = await aggregatePluginData(
pluginId!,
widget.entity,
widget.lane_field,
);
return { widget, data };
}
// card_list — 卡片列表
if (widget.type === 'card_list') {
const filterObj = widget.filter ? JSON.parse(widget.filter) : undefined;
const result = await listPluginData(pluginId!, widget.entity, 1, widget.max_items ?? 10, {
filter: filterObj,
});
return { widget, data: [], records: result.data };
}
// 旧类型图表
if (widget.dimension_field) {
const data = await aggregatePluginData(
pluginId!,
@@ -146,7 +201,7 @@ export function PluginDashboardPage() {
);
return { widget, data };
}
// 没有 dimension_field 时仅返回计数
// fallback — 仅返回计数
const count = await countPluginData(pluginId!, widget.entity);
return { widget, data: [], count };
} catch {
@@ -244,7 +299,7 @@ export function PluginDashboardPage() {
style={{
fontSize: 24,
fontWeight: 700,
color: isDark ? '#F1F5F9' : '#0F172A',
color: isDark ? '#f8fafc' : 'rgba(0,0,0,0.95)',
margin: '0 0 4px',
letterSpacing: '-0.5px',
}}
@@ -254,7 +309,7 @@ export function PluginDashboardPage() {
<p
style={{
fontSize: 14,
color: isDark ? '#94A3B8' : '#475569',
color: isDark ? '#94a3b8' : '#475569',
margin: 0,
}}
>
@@ -297,7 +352,7 @@ export function PluginDashboardPage() {
<div className="erp-section-header">
<DashboardOutlined
className="erp-section-icon"
style={{ color: '#4F46E5' }}
style={{ color: '#2563eb' }}
/>
<span className="erp-section-title"></span>
</div>
@@ -313,6 +368,10 @@ export function PluginDashboardPage() {
{widgetData.map((wd) => {
const colSpan = wd.widget.type === 'stat_card' ? 6
: wd.widget.type === 'pie_chart' || wd.widget.type === 'funnel_chart' ? 12
: wd.widget.type === 'stat_cards' ? 24
: wd.widget.type === 'action_list' ? 12
: wd.widget.type === 'funnel' ? 12
: wd.widget.type === 'card_list' ? 12
: 12;
return (
<Col key={`${wd.widget.type}-${wd.widget.entity}-${wd.widget.title}`} xs={24} sm={colSpan}>
@@ -330,7 +389,7 @@ export function PluginDashboardPage() {
<div className="erp-section-header">
<DashboardOutlined
className="erp-section-icon"
style={{ color: currentPalette.tagColor === 'purple' ? '#4F46E5' : '#3B82F6' }}
style={{ color: currentPalette.tagColor === 'purple' ? '#2563eb' : '#3B82F6' }}
/>
<span className="erp-section-title">
{currentEntity?.display_name || selectedEntity}

View File

@@ -313,8 +313,8 @@ export function PluginGraphPage() {
const r = degreeToRadius(degree, isCenter);
// Determine node color from its most common edge type, or default palette
let nodeColorBase = '#4F46E5';
let nodeColorLight = '#818CF8';
let nodeColorBase = '#2563eb';
let nodeColorLight = '#60a5fa';
let nodeColorGlow = 'rgba(79,70,229,0.3)';
if (isCenter) {

View File

@@ -40,9 +40,9 @@ const CATEGORY_COLORS: Record<string, string> = {
'财务': '#059669',
'CRM': '#2563EB',
'进销存': '#9333EA',
'生产': '#DC2626',
'人力资源': '#D97706',
'基础': '#64748B',
'生产': '#dc2626',
'人力资源': '#d97706',
'基础': '#475569',
};
export default function PluginMarket() {
@@ -190,7 +190,7 @@ export default function PluginMarket() {
<div style={{ marginBottom: 8 }}>
<Text strong style={{ fontSize: 16 }}>{plugin.name}</Text>
<Tag
color={CATEGORY_COLORS[plugin.category ?? ''] ?? '#64748B'}
color={CATEGORY_COLORS[plugin.category ?? ''] ?? '#475569'}
style={{ marginLeft: 8 }}
>
{plugin.category}
@@ -244,7 +244,7 @@ export default function PluginMarket() {
<div>
<div style={{ marginBottom: 16 }}>
<Space>
<Tag color={CATEGORY_COLORS[selectedPlugin.category ?? ''] ?? '#64748B'}>
<Tag color={CATEGORY_COLORS[selectedPlugin.category ?? ''] ?? '#475569'}>
{selectedPlugin.category}
</Tag>
<Text type="secondary">v{selectedPlugin.version}</Text>

View File

@@ -153,12 +153,12 @@ export default function Roles() {
height: 32,
borderRadius: 8,
background: record.is_system
? 'linear-gradient(135deg, #4F46E5, #818CF8)'
: isDark ? '#1E293B' : '#F1F5F9',
? 'linear-gradient(135deg, #2563eb, #60a5fa)'
: isDark ? '#0f172a' : '#f8fafc',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: record.is_system ? '#fff' : isDark ? '#94A3B8' : '#64748B',
color: record.is_system ? '#fff' : isDark ? '#94a3b8' : '#475569',
fontSize: 14,
}}
>
@@ -174,9 +174,9 @@ export default function Roles() {
key: 'code',
render: (v: string) => (
<Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9',
background: isDark ? '#0f172a' : '#f8fafc',
border: 'none',
color: isDark ? '#94A3B8' : '#64748B',
color: isDark ? '#94a3b8' : '#475569',
fontFamily: 'monospace',
fontSize: 12,
}}>
@@ -190,7 +190,7 @@ export default function Roles() {
key: 'description',
ellipsis: true,
render: (v: string | undefined) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8' }}>{v || '-'}</span>
<span style={{ color: isDark ? '#475569' : '#94a3b8' }}>{v || '-'}</span>
),
},
{
@@ -201,8 +201,8 @@ export default function Roles() {
render: (v: boolean) => (
<Tag
style={{
color: v ? '#4F46E5' : (isDark ? '#94A3B8' : '#64748B'),
background: v ? '#EEF2FF' : (isDark ? '#1E293B' : '#F1F5F9'),
color: v ? '#2563eb' : (isDark ? '#94a3b8' : '#475569'),
background: v ? '#eff6ff' : (isDark ? '#0f172a' : '#f8fafc'),
border: 'none',
fontWeight: 500,
}}
@@ -222,7 +222,7 @@ export default function Roles() {
type="text"
icon={<SafetyCertificateOutlined />}
onClick={() => openPermModal(record)}
style={{ color: '#4F46E5' }}
style={{ color: '#2563eb' }}
>
</Button>
@@ -233,7 +233,7 @@ export default function Roles() {
type="text"
icon={<EditOutlined />}
onClick={() => openEditModal(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
<Popconfirm
title="确定删除此角色?"
@@ -279,7 +279,7 @@ export default function Roles() {
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}>
<Table
@@ -336,8 +336,8 @@ export default function Roles() {
marginBottom: 16,
padding: 16,
borderRadius: 10,
border: `1px solid ${isDark ? '#1E293B' : '#E2E8F0'}`,
background: isDark ? '#0B0F1A' : '#F8FAFC',
border: `1px solid ${isDark ? '#0f172a' : '#E2E8F0'}`,
background: isDark ? '#0B0F1A' : '#f1f5f9',
}}
>
<div style={{

View File

@@ -36,8 +36,8 @@ import type { UserInfo } from '../api/auth';
const STATUS_COLOR_MAP: Record<string, string> = {
active: '#059669',
disabled: '#DC2626',
locked: '#D97706',
disabled: '#dc2626',
locked: '#d97706',
};
const STATUS_BG_MAP: Record<string, string> = {
@@ -219,7 +219,7 @@ export default function Users() {
width: 32,
height: 32,
borderRadius: 8,
background: 'linear-gradient(135deg, #4F46E5, #818CF8)',
background: 'linear-gradient(135deg, #2563eb, #60a5fa)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -233,7 +233,7 @@ export default function Users() {
<div>
<div style={{ fontWeight: 500, fontSize: 14 }}>{v}</div>
{record.display_name && (
<div style={{ fontSize: 12, color: isDark ? '#64748B' : '#94A3B8' }}>
<div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8' }}>
{record.display_name}
</div>
)}
@@ -261,8 +261,8 @@ export default function Users() {
render: (status: string) => (
<Tag
style={{
color: STATUS_COLOR_MAP[status] || '#64748B',
background: STATUS_BG_MAP[status] || '#F1F5F9',
color: STATUS_COLOR_MAP[status] || '#62625b',
background: STATUS_BG_MAP[status] || '#f8fafc',
border: 'none',
fontWeight: 500,
}}
@@ -279,7 +279,7 @@ export default function Users() {
roles.length > 0
? roles.map((r) => (
<Tag key={r.id} style={{
background: isDark ? '#1E293B' : '#F1F5F9',
background: isDark ? '#0f172a' : '#f8fafc',
border: 'none',
color: isDark ? '#CBD5E1' : '#475569',
}}>
@@ -299,14 +299,14 @@ export default function Users() {
type="text"
icon={<EditOutlined />}
onClick={() => openEditModal(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
<Button
size="small"
type="text"
icon={<SafetyCertificateOutlined />}
onClick={() => openRoleModal(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
{record.status === 'active' ? (
<Popconfirm
@@ -356,7 +356,7 @@ export default function Users() {
<Space size={8}>
<Input
placeholder="搜索用户名..."
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />}
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
@@ -379,7 +379,7 @@ export default function Users() {
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}>
<Table
@@ -415,7 +415,7 @@ export default function Users() {
label="用户名"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input prefix={<UserOutlined style={{ color: '#94A3B8' }} />} disabled={!!editUser} />
<Input prefix={<UserOutlined style={{ color: '#94a3b8' }} />} disabled={!!editUser} />
</Form.Item>
{!editUser && (
<Form.Item
@@ -465,13 +465,13 @@ export default function Users() {
style={{
padding: '10px 14px',
borderRadius: 8,
border: `1px solid ${isDark ? '#1E293B' : '#E2E8F0'}`,
background: isDark ? '#0B0F1A' : '#F8FAFC',
border: `1px solid ${isDark ? '#0f172a' : '#E2E8F0'}`,
background: isDark ? '#0B0F1A' : '#f1f5f9',
}}
>
<Checkbox value={r.id}>
<span style={{ fontWeight: 500 }}>{r.name}</span>
<span style={{ color: isDark ? '#475569' : '#94A3B8', marginLeft: 8, fontSize: 12 }}>
<span style={{ color: isDark ? '#475569' : '#94a3b8', marginLeft: 8, fontSize: 12 }}>
{r.code}
</span>
</Checkbox>

View File

@@ -1,11 +1,13 @@
import { useEffect, useState, useRef } from 'react';
import { Col, Spin, Empty, Tag, Progress, Skeleton, Tooltip, Card, Typography } from 'antd';
import { Col, Row, Spin, Empty, Tag, Progress, Skeleton, Tooltip, Card, Typography, List, Badge } from 'antd';
import {
InfoCircleOutlined,
DashboardOutlined,
RightOutlined,
} from '@ant-design/icons';
import { Column, Pie, Funnel, Line } from '@ant-design/charts';
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboardTypes';
import type { ActionQueryDef } from '../../api/plugins';
import { TAG_COLORS, WIDGET_ICON_MAP } from './dashboardConstants';
// ── 计数动画 Hook ──
@@ -44,7 +46,7 @@ function prepareChartData(data: WidgetData['data'], dimensionOrder?: string[]) {
const TAG_COLOR_MAP: Record<string, string> = {
blue: '#3B82F6', green: '#10B981', orange: '#F59E0B', red: '#EF4444',
purple: '#8B5CF6', cyan: '#06B6D4', magenta: '#EC4899', gold: '#EAB308',
lime: '#84CC16', geekblue: '#6366F1', volcano: '#F97316',
lime: '#84CC16', geekblue: '#60a5fa', volcano: '#F97316',
};
function tagStrokeColor(color: string): string {
@@ -202,7 +204,7 @@ export function SkeletonBreakdownCard({ index }: { index: number }) {
function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) {
const { widget, count } = widgetData;
const animatedValue = useCountUp(count ?? 0);
const color = widget.color || '#4F46E5';
const color = widget.color || '#2563eb';
return (
<Card size="small" className="erp-fade-in" style={{ height: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
@@ -227,7 +229,7 @@ function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) {
function BarWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
const { widget, data } = widgetData;
const chartData = prepareChartData(data, widget.dimension_order);
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' };
const axisLabelStyle = { fill: isDark ? '#94a3b8' : '#475569' };
return (
<WidgetCardShell title={widget.title} widgetType={widget.type}>
{chartData.length > 0 ? (
@@ -273,7 +275,7 @@ function FunnelWidgetCard({ widgetData }: { widgetData: WidgetData }) {
function LineWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
const { widget, data } = widgetData;
const chartData = prepareChartData(data, widget.dimension_order);
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' };
const axisLabelStyle = { fill: isDark ? '#94a3b8' : '#475569' };
return (
<WidgetCardShell title={widget.title} widgetType={widget.type}>
{chartData.length > 0 ? (
@@ -293,6 +295,146 @@ export function WidgetRenderer({ widgetData, isDark }: { widgetData: WidgetData;
case 'pie_chart': return <PieWidgetCard widgetData={widgetData} />;
case 'funnel_chart': return <FunnelWidgetCard widgetData={widgetData} />;
case 'line_chart': return <LineWidgetCard widgetData={widgetData} isDark={isDark} />;
case 'stat_cards': return <StatCardsWidget widgetData={widgetData} />;
case 'action_list': return <ActionListWidget widgetData={widgetData} />;
case 'funnel': return <FunnelStageWidget widgetData={widgetData} />;
case 'card_list': return <CardListWidget widgetData={widgetData} />;
default: return null;
}
}
// ── Manifest Widget 渲染器 ──
/** stat_cards — 多个统计卡片 */
function StatCardsWidget({ widgetData }: { widgetData: WidgetData }) {
const { statCards, widget } = widgetData;
if (!statCards || statCards.length === 0) return <ChartEmpty />;
return (
<Card size="small" title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP.stat_cards} {widget.label || widget.title}</span>} className="erp-fade-in">
<Row gutter={[12, 12]}>
{statCards.map((sc, i) => (
<Col xs={12} sm={6} key={`${sc.card.entity}-${sc.card.label}-${i}`}>
<div style={{
background: `${sc.card.color || '#2563eb'}10`,
borderRadius: 8,
padding: '12px 16px',
display: 'flex',
alignItems: 'center',
gap: 10,
}}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: `${sc.card.color || '#2563eb'}20`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: sc.card.color || '#2563eb', fontSize: 18,
}}>
<DashboardOutlined />
</div>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{sc.card.label}</Typography.Text>
<div style={{ fontSize: 20, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
{sc.value.toLocaleString()}
</div>
</div>
</div>
</Col>
))}
</Row>
</Card>
);
}
/** action_list — 待办列表 */
function ActionListWidget({ widgetData }: { widgetData: WidgetData }) {
const { actionItems, widget } = widgetData;
if (!actionItems) return <ChartEmpty />;
const allItems = actionItems.flatMap((ai) =>
ai.records.map((r) => ({ ...r, _query: ai.query })),
);
const maxItems = widget.max_items ?? 10;
const displayItems = allItems.slice(0, maxItems);
return (
<Card size="small" title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP.action_list} {widget.label || widget.title}</span>} className="erp-fade-in">
{displayItems.length > 0 ? (
<List
size="small"
dataSource={displayItems}
renderItem={(item) => {
const q = item._query as ActionQueryDef;
const title = String(item.data?.[q.label_field] ?? '-');
const subtitle = q.subtitle_field ? String(item.data?.[q.subtitle_field] ?? '') : '';
return (
<List.Item style={{ padding: '8px 0', cursor: 'pointer' }}>
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
<Badge color="blue" />
<div style={{ flex: 1, overflow: 'hidden' }}>
<div style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</div>
{subtitle && <Typography.Text type="secondary" style={{ fontSize: 12 }}>{subtitle}</Typography.Text>}
</div>
<RightOutlined style={{ fontSize: 12, color: 'var(--erp-text-quaternary)' }} />
</div>
</List.Item>
);
}}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待办" />
)}
</Card>
);
}
/** funnel — 阶段漏斗 */
function FunnelStageWidget({ widgetData }: { widgetData: WidgetData }) {
const { data, widget } = widgetData;
const chartData = (widget.lane_order ?? [])
.map((key) => {
const found = data.find((d) => d.key === key);
return { key, count: found?.count ?? 0 };
})
.filter((d) => d.count > 0);
return (
<WidgetCardShell title={widget.label || widget.title} widgetType="funnel_chart">
{chartData.length > 0 ? (
<Funnel data={chartData} xField="key" yField="count" legend={{ position: 'bottom' as const }} />
) : <ChartEmpty />}
</WidgetCardShell>
);
}
/** card_list — 卡片列表 */
function CardListWidget({ widgetData }: { widgetData: WidgetData }) {
const { records, widget } = widgetData;
const maxItems = widget.max_items ?? 10;
const displayRecords = (records ?? []).slice(0, maxItems);
return (
<Card size="small" title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP.card_list} {widget.label || widget.title}</span>} className="erp-fade-in">
{displayRecords.length > 0 ? (
<List
size="small"
dataSource={displayRecords}
renderItem={(item) => {
const title = String(item.data?.[widget.title_field ?? 'name'] ?? '-');
const subtitle = widget.subtitle_field ? String(item.data?.[widget.subtitle_field] ?? '') : '';
const tagValues = (widget.tags ?? []).map((t) => String(item.data?.[t] ?? '')).filter(Boolean);
return (
<List.Item style={{ padding: '8px 0' }}>
<div style={{ width: '100%' }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{title}</div>
{subtitle && <Typography.Text type="secondary" style={{ fontSize: 12 }}>{subtitle}</Typography.Text>}
{tagValues.length > 0 && (
<div style={{ marginTop: 4 }}>
{tagValues.map((tv, i) => <Tag key={i} style={{ fontSize: 11 }}>{tv}</Tag>)}
</div>
)}
</div>
</List.Item>
);
}}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />
)}
</Card>
);
}

View File

@@ -19,9 +19,9 @@ import {
// ── 通用调色板 ──
const UNIVERSAL_COLORS = [
{ gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)', iconBg: 'rgba(79, 70, 229, 0.12)', tagColor: 'purple' },
{ gradient: 'linear-gradient(135deg, #2563eb, #60a5fa)', iconBg: 'rgba(79, 70, 229, 0.12)', tagColor: 'purple' },
{ gradient: 'linear-gradient(135deg, #059669, #10B981)', iconBg: 'rgba(5, 150, 105, 0.12)', tagColor: 'green' },
{ gradient: 'linear-gradient(135deg, #D97706, #F59E0B)', iconBg: 'rgba(217, 119, 6, 0.12)', tagColor: 'orange' },
{ gradient: 'linear-gradient(135deg, #d97706, #F59E0B)', iconBg: 'rgba(217, 119, 6, 0.12)', tagColor: 'orange' },
{ gradient: 'linear-gradient(135deg, #7C3AED, #A78BFA)', iconBg: 'rgba(124, 58, 237, 0.12)', tagColor: 'volcano' },
{ gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)', iconBg: 'rgba(225, 29, 72, 0.12)', tagColor: 'red' },
{ gradient: 'linear-gradient(135deg, #0891B2, #06B6D4)', iconBg: 'rgba(8, 145, 178, 0.12)', tagColor: 'cyan' },
@@ -82,6 +82,10 @@ export const WIDGET_ICON_MAP: Record<string, React.ReactNode> = {
pie_chart: <PieChartOutlined />,
funnel_chart: <FunnelPlotOutlined />,
line_chart: <LineChartOutlined />,
stat_cards: <DashboardOutlined />,
action_list: <AppstoreOutlined />,
funnel: <FunnelPlotOutlined />,
card_list: <DatabaseOutlined />,
};
// ── 延迟类名工具 ──

View File

@@ -1,6 +1,6 @@
import type React from 'react';
import type { AggregateItem } from '../../api/pluginData';
import type { DashboardWidget } from '../../api/plugins';
import type { AggregateItem, PluginDataRecord } from '../../api/pluginData';
import type { DashboardWidget, StatCardDef, ActionQueryDef } from '../../api/plugins';
// ── 类型定义 ──
@@ -23,4 +23,7 @@ export interface WidgetData {
widget: DashboardWidget;
data: AggregateItem[];
count?: number;
records?: PluginDataRecord[];
statCards?: { card: StatCardDef; value: number }[];
actionItems?: { query: ActionQueryDef; records: PluginDataRecord[] }[];
}

View File

@@ -11,11 +11,11 @@ import type { GraphEdge } from './graphTypes';
/** 关系类型对应的色板 (base / light / glow) — 通用调色板自动分配 */
const EDGE_PALETTE: Array<{ base: string; light: string; glow: string }> = [
{ base: '#4F46E5', light: '#818CF8', glow: 'rgba(79,70,229,0.3)' },
{ base: '#2563eb', light: '#60a5fa', glow: 'rgba(79,70,229,0.3)' },
{ base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' },
{ base: '#D97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' },
{ base: '#d97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' },
{ base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' },
{ base: '#DC2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' },
{ base: '#dc2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' },
{ base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' },
{ base: '#EA580C', light: '#FB923C', glow: 'rgba(234,88,12,0.3)' },
{ base: '#DB2777', light: '#F472B6', glow: 'rgba(219,39,119,0.3)' },

View File

@@ -5,9 +5,9 @@ import type { ColumnsType } from 'antd/es/table';
import { listTemplates, createTemplate, type MessageTemplateInfo } from '../../api/messageTemplates';
const channelMap: Record<string, { label: string; color: string }> = {
in_app: { label: '站内', color: '#4F46E5' },
in_app: { label: '站内', color: '#2563eb' },
email: { label: '邮件', color: '#059669' },
sms: { label: '短信', color: '#D97706' },
sms: { label: '短信', color: '#d97706' },
wechat: { label: '微信', color: '#7C3AED' },
};
@@ -64,9 +64,9 @@ export default function MessageTemplates() {
key: 'code',
render: (v: string) => (
<Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9',
background: isDark ? '#0f172a' : '#f8fafc',
border: 'none',
color: isDark ? '#94A3B8' : '#64748B',
color: isDark ? '#94a3b8' : '#475569',
fontFamily: 'monospace',
fontSize: 12,
}}>
@@ -80,7 +80,7 @@ export default function MessageTemplates() {
key: 'channel',
width: 90,
render: (c: string) => {
const info = channelMap[c] || { label: c, color: '#64748B' };
const info = channelMap[c] || { label: c, color: '#475569' };
return (
<Tag style={{
background: info.color + '15',
@@ -111,7 +111,7 @@ export default function MessageTemplates() {
key: 'created_at',
width: 180,
render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>{v}</span>
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>{v}</span>
),
},
];
@@ -124,7 +124,7 @@ export default function MessageTemplates() {
alignItems: 'center',
marginBottom: 16,
}}>
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}>
<span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8' }}>
{total}
</span>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
@@ -135,7 +135,7 @@ export default function MessageTemplates() {
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}>
<Table

View File

@@ -11,9 +11,9 @@ interface Props {
}
const priorityStyles: Record<string, { bg: string; color: string; text: string }> = {
urgent: { bg: '#FEF2F2', color: '#DC2626', text: '紧急' },
important: { bg: '#FFFBEB', color: '#D97706', text: '重要' },
normal: { bg: '#EEF2FF', color: '#4F46E5', text: '普通' },
urgent: { bg: '#FEF2F2', color: '#dc2626', text: '紧急' },
important: { bg: '#FFFBEB', color: '#d97706', text: '重要' },
normal: { bg: '#eff6ff', color: '#2563eb', text: '普通' },
};
export default function NotificationList({ queryFilter }: Props) {
@@ -83,7 +83,7 @@ export default function NotificationList({ queryFilter }: Props) {
content: (
<div>
<Paragraph>{record.body}</Paragraph>
<div style={{ marginTop: 8, color: isDark ? '#475569' : '#94A3B8', fontSize: 12 }}>
<div style={{ marginTop: 8, color: isDark ? '#475569' : '#94a3b8', fontSize: 12 }}>
{record.created_at}
</div>
</div>
@@ -104,7 +104,7 @@ export default function NotificationList({ queryFilter }: Props) {
style={{
fontWeight: record.is_read ? 400 : 600,
cursor: 'pointer',
color: record.is_read ? (isDark ? '#94A3B8' : '#64748B') : 'inherit',
color: record.is_read ? (isDark ? '#94a3b8' : '#475569') : 'inherit',
}}
onClick={() => showDetail(record)}
>
@@ -114,7 +114,7 @@ export default function NotificationList({ queryFilter }: Props) {
width: 6,
height: 6,
borderRadius: '50%',
background: '#4F46E5',
background: '#2563eb',
marginRight: 8,
}} />
)}
@@ -128,7 +128,7 @@ export default function NotificationList({ queryFilter }: Props) {
key: 'priority',
width: 90,
render: (p: string) => {
const info = priorityStyles[p] || { bg: '#F1F5F9', color: '#64748B', text: p };
const info = priorityStyles[p] || { bg: '#f8fafc', color: '#475569', text: p };
return (
<Tag style={{
background: info.bg,
@@ -146,7 +146,7 @@ export default function NotificationList({ queryFilter }: Props) {
dataIndex: 'sender_type',
key: 'sender_type',
width: 80,
render: (s: string) => <span style={{ color: isDark ? '#64748B' : '#94A3B8' }}>{s === 'system' ? '系统' : '用户'}</span>,
render: (s: string) => <span style={{ color: isDark ? '#475569' : '#94a3b8' }}>{s === 'system' ? '系统' : '用户'}</span>,
},
{
title: '状态',
@@ -155,9 +155,9 @@ export default function NotificationList({ queryFilter }: Props) {
width: 80,
render: (r: boolean) => (
<Tag style={{
background: r ? (isDark ? '#1E293B' : '#F1F5F9') : '#EEF2FF',
background: r ? (isDark ? '#0f172a' : '#f8fafc') : '#eff6ff',
border: 'none',
color: r ? (isDark ? '#64748B' : '#94A3B8') : '#4F46E5',
color: r ? (isDark ? '#475569' : '#94a3b8') : '#2563eb',
fontWeight: 500,
}}>
{r ? '已读' : '未读'}
@@ -170,7 +170,7 @@ export default function NotificationList({ queryFilter }: Props) {
key: 'created_at',
width: 180,
render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>{v}</span>
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>{v}</span>
),
},
{
@@ -185,7 +185,7 @@ export default function NotificationList({ queryFilter }: Props) {
size="small"
icon={<CheckOutlined />}
onClick={() => handleMarkRead(record.id)}
style={{ color: '#4F46E5' }}
style={{ color: '#2563eb' }}
/>
)}
<Button
@@ -193,7 +193,7 @@ export default function NotificationList({ queryFilter }: Props) {
size="small"
icon={<EyeOutlined />}
onClick={() => showDetail(record)}
style={{ color: isDark ? '#64748B' : '#94A3B8' }}
style={{ color: isDark ? '#475569' : '#94a3b8' }}
/>
<Button
type="text"
@@ -215,7 +215,7 @@ export default function NotificationList({ queryFilter }: Props) {
alignItems: 'center',
marginBottom: 16,
}}>
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}>
<span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8' }}>
{total}
</span>
<Button icon={<CheckOutlined />} onClick={handleMarkAllRead}>
@@ -226,7 +226,7 @@ export default function NotificationList({ queryFilter }: Props) {
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}>
<Table

View File

@@ -48,12 +48,12 @@ export default function NotificationPreferences() {
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
padding: 24,
maxWidth: 600,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 20 }}>
<BellOutlined style={{ fontSize: 16, color: '#4F46E5' }} />
<BellOutlined style={{ fontSize: 16, color: '#2563eb' }} />
<span style={{ fontSize: 15, fontWeight: 600 }}></span>
</div>

View File

@@ -5,11 +5,11 @@
// 通用边调色板
const EDGE_PALETTE: Array<{ base: string; light: string; glow: string }> = [
{ base: '#4F46E5', light: '#818CF8', glow: 'rgba(79,70,229,0.3)' },
{ base: '#2563eb', light: '#60a5fa', glow: 'rgba(79,70,229,0.3)' },
{ base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' },
{ base: '#D97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' },
{ base: '#d97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' },
{ base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' },
{ base: '#DC2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' },
{ base: '#dc2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' },
{ base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' },
{ base: '#EA580C', light: '#FB923C', glow: 'rgba(234,88,12,0.3)' },
{ base: '#DB2777', light: '#F472B6', glow: 'rgba(219,39,119,0.3)' },

View File

@@ -295,8 +295,8 @@ export function drawFullGraph(
const degree = degreeMap.get(node.id) || 0;
const r = degreeToRadius(degree, isCenter);
let nodeColorBase = '#4F46E5';
let nodeColorLight = '#818CF8';
let nodeColorBase = '#2563eb';
let nodeColorLight = '#60a5fa';
let nodeColorGlow = 'rgba(79,70,229,0.3)';
if (isCenter) {

View File

@@ -18,8 +18,8 @@ const RESOURCE_TYPE_OPTIONS = [
const ACTION_STYLES: Record<string, { bg: string; color: string; text: string }> = {
create: { bg: '#ECFDF5', color: '#059669', text: '创建' },
update: { bg: '#EEF2FF', color: '#4F46E5', text: '更新' },
delete: { bg: '#FEF2F2', color: '#DC2626', text: '删除' },
update: { bg: '#eff6ff', color: '#2563eb', text: '更新' },
delete: { bg: '#FEF2F2', color: '#dc2626', text: '删除' },
};
function formatDateTime(value: string): string {
@@ -80,7 +80,7 @@ export default function AuditLogViewer() {
key: 'action',
width: 100,
render: (action: string) => {
const info = ACTION_STYLES[action] || { bg: '#F1F5F9', color: '#64748B', text: action };
const info = ACTION_STYLES[action] || { bg: '#f8fafc', color: '#475569', text: action };
return (
<Tag style={{
background: info.bg,
@@ -100,7 +100,7 @@ export default function AuditLogViewer() {
width: 120,
render: (v: string) => (
<Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9',
background: isDark ? '#0f172a' : '#f8fafc',
border: 'none',
color: isDark ? '#CBD5E1' : '#475569',
}}>
@@ -115,7 +115,7 @@ export default function AuditLogViewer() {
width: 200,
ellipsis: true,
render: (v: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94A3B8' : '#64748B' }}>
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94a3b8' : '#475569' }}>
{v}
</span>
),
@@ -127,7 +127,7 @@ export default function AuditLogViewer() {
width: 200,
ellipsis: true,
render: (v: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94A3B8' : '#64748B' }}>
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94a3b8' : '#475569' }}>
{v}
</span>
),
@@ -138,7 +138,7 @@ export default function AuditLogViewer() {
key: 'created_at',
width: 180,
render: (value: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{formatDateTime(value)}
</span>
),
@@ -156,7 +156,7 @@ export default function AuditLogViewer() {
padding: 12,
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 10,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}}>
<Select
allowClear
@@ -173,7 +173,7 @@ export default function AuditLogViewer() {
value={query.user_id ?? ''}
onChange={(e) => handleFilterChange('user_id', e.target.value)}
/>
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8', marginLeft: 'auto' }}>
<span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8', marginLeft: 'auto' }}>
{total}
</span>
</div>
@@ -182,7 +182,7 @@ export default function AuditLogViewer() {
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}>
<Table

View File

@@ -132,7 +132,7 @@ export default function SystemSettings() {
width: 250,
render: (v: string) => (
<Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9',
background: isDark ? '#0f172a' : '#f8fafc',
border: 'none',
color: isDark ? '#CBD5E1' : '#475569',
fontFamily: 'monospace',
@@ -162,7 +162,7 @@ export default function SystemSettings() {
type="text"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
<Popconfirm
title="确定删除此设置?"
@@ -191,7 +191,7 @@ export default function SystemSettings() {
<Space>
<Input
placeholder="输入设置键名查询"
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />}
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onPressEnter={handleSearch}
@@ -207,7 +207,7 @@ export default function SystemSettings() {
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}>
<Table

View File

@@ -5,8 +5,8 @@ import { listCompletedTasks, type TaskInfo } from '../../api/workflowTasks';
const outcomeStyles: Record<string, { bg: string; color: string; text: string }> = {
approved: { bg: '#ECFDF5', color: '#059669', text: '同意' },
rejected: { bg: '#FEF2F2', color: '#DC2626', text: '拒绝' },
delegated: { bg: '#EEF2FF', color: '#4F46E5', text: '已委派' },
rejected: { bg: '#FEF2F2', color: '#dc2626', text: '拒绝' },
delegated: { bg: '#eff6ff', color: '#2563eb', text: '已委派' },
};
export default function CompletedTasks() {
@@ -50,7 +50,7 @@ export default function CompletedTasks() {
key: 'outcome',
width: 100,
render: (o: string) => {
const info = outcomeStyles[o] || { bg: '#F1F5F9', color: '#64748B', text: o };
const info = outcomeStyles[o] || { bg: '#f8fafc', color: '#475569', text: o };
return (
<Tag style={{
background: info.bg,
@@ -69,7 +69,7 @@ export default function CompletedTasks() {
key: 'completed_at',
width: 180,
render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{v ? new Date(v).toLocaleString() : '-'}
</span>
),
@@ -80,7 +80,7 @@ export default function CompletedTasks() {
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}>
<Table

View File

@@ -13,10 +13,10 @@ import { getProcessDefinition, type NodeDef, type EdgeDef } from '../../api/work
import ProcessViewer from './ProcessViewer';
const statusStyles: Record<string, { bg: string; color: string; text: string }> = {
running: { bg: '#EEF2FF', color: '#4F46E5', text: '运行中' },
suspended: { bg: '#FFFBEB', color: '#D97706', text: '已挂起' },
running: { bg: '#eff6ff', color: '#2563eb', text: '运行中' },
suspended: { bg: '#FFFBEB', color: '#d97706', text: '已挂起' },
completed: { bg: '#ECFDF5', color: '#059669', text: '已完成' },
terminated: { bg: '#FEF2F2', color: '#DC2626', text: '已终止' },
terminated: { bg: '#FEF2F2', color: '#dc2626', text: '已终止' },
};
export default function InstanceMonitor() {
@@ -129,7 +129,7 @@ export default function InstanceMonitor() {
key: 'status',
width: 100,
render: (s: string) => {
const info = statusStyles[s] || { bg: '#F1F5F9', color: '#64748B', text: s };
const info = statusStyles[s] || { bg: '#f8fafc', color: '#475569', text: s };
return (
<Tag style={{
background: info.bg,
@@ -154,7 +154,7 @@ export default function InstanceMonitor() {
key: 'started_at',
width: 180,
render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{new Date(v).toLocaleString()}
</span>
),
@@ -214,7 +214,7 @@ export default function InstanceMonitor() {
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}>
<Table

View File

@@ -76,9 +76,9 @@ export default function PendingTasks() {
key: 'business_key',
render: (v: string | undefined) => v ? (
<Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9',
background: isDark ? '#0f172a' : '#f8fafc',
border: 'none',
color: isDark ? '#94A3B8' : '#64748B',
color: isDark ? '#94a3b8' : '#475569',
fontFamily: 'monospace',
fontSize: 12,
}}>
@@ -93,9 +93,9 @@ export default function PendingTasks() {
width: 100,
render: (s: string) => (
<Tag style={{
background: '#EEF2FF',
background: '#eff6ff',
border: 'none',
color: '#4F46E5',
color: '#2563eb',
fontWeight: 500,
}}>
{s}
@@ -108,7 +108,7 @@ export default function PendingTasks() {
key: 'created_at',
width: 180,
render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{new Date(v).toLocaleString()}
</span>
),
@@ -145,7 +145,7 @@ export default function PendingTasks() {
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}>
<Table

View File

@@ -13,9 +13,9 @@ import {
import ProcessDesigner from './ProcessDesigner';
const statusColors: Record<string, { bg: string; color: string; text: string }> = {
draft: { bg: '#F1F5F9', color: '#64748B', text: '草稿' },
published: { bg: '#ECFDF5', color: '#059669', text: '已发布' },
deprecated: { bg: '#FEF2F2', color: '#DC2626', text: '已弃用' },
draft: { bg: '#f8fafc', color: '#475569', text: '草稿' },
published: { bg: '#ecfdf5', color: '#059669', text: '已发布' },
deprecated: { bg: '#fef2f2', color: '#dc2626', text: '已弃用' },
};
export default function ProcessDefinitions() {
@@ -92,9 +92,9 @@ export default function ProcessDefinitions() {
key: 'key',
render: (v: string) => (
<Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9',
background: isDark ? '#1E293B' : '#f8fafc',
border: 'none',
color: isDark ? '#94A3B8' : '#64748B',
color: isDark ? '#94a3b8' : '#475569',
fontFamily: 'monospace',
fontSize: 12,
}}>
@@ -110,7 +110,7 @@ export default function ProcessDefinitions() {
key: 'status',
width: 100,
render: (s: string) => {
const info = statusColors[s] || { bg: '#F1F5F9', color: '#64748B', text: s };
const info = statusColors[s] || { bg: '#f8fafc', color: '#475569', text: s };
return (
<Tag style={{
background: info.bg,
@@ -152,7 +152,7 @@ export default function ProcessDefinitions() {
alignItems: 'center',
marginBottom: 16,
}}>
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}>
<span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8' }}>
{total}
</span>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
@@ -163,7 +163,7 @@ export default function ProcessDefinitions() {
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
border: `1px solid ${isDark ? '#1E293B' : '#f8fafc'}`,
overflow: 'hidden',
}}>
<Table

View File

@@ -11,7 +11,7 @@
*/
interface ExprNode {
type: 'eq' | 'and' | 'or' | 'not';
type: 'eq' | 'neq' | 'and' | 'or' | 'not';
field?: string;
value?: string;
left?: ExprNode;
@@ -49,6 +49,16 @@ function tokenize(input: string): string[] {
i += 2;
continue;
}
if (input[i] === '&' && input[i + 1] === '&') {
tokens.push('&&');
i += 2;
continue;
}
if (input[i] === '|' && input[i + 1] === '|') {
tokens.push('||');
i += 2;
continue;
}
let j = i;
while (
j < input.length &&
@@ -81,12 +91,12 @@ function parseAtom(tokens: string[]): ExprNode | null {
if (op !== '==' && op !== '!=') return null;
const rawValue = tokens.shift() || '';
const value = rawValue.replace(/^'(.*)'$/, '$1');
return { type: 'eq', field, value };
return { type: op === '!=' ? 'neq' : 'eq', field, value };
}
function parseAnd(tokens: string[]): ExprNode | null {
let left = parseAtom(tokens);
while (tokens[0] === 'AND') {
while (tokens[0] === 'AND' || tokens[0] === '&&') {
tokens.shift();
const right = parseAtom(tokens);
if (left && right) {
@@ -98,7 +108,7 @@ function parseAnd(tokens: string[]): ExprNode | null {
function parseOr(tokens: string[]): ExprNode | null {
let left = parseAnd(tokens);
while (tokens[0] === 'OR') {
while (tokens[0] === 'OR' || tokens[0] === '||') {
tokens.shift();
const right = parseAnd(tokens);
if (left && right) {
@@ -117,6 +127,8 @@ export function evaluateExpr(node: ExprNode, values: Record<string, unknown>): b
switch (node.type) {
case 'eq':
return String(values[node.field!] ?? '') === node.value;
case 'neq':
return String(values[node.field!] ?? '') !== node.value;
case 'and':
return evaluateExpr(node.left!, values) && evaluateExpr(node.right!, values);
case 'or':

View File

@@ -0,0 +1,19 @@
[package]
name = "erp-health"
version.workspace = true
edition.workspace = true
[dependencies]
erp-core.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
uuid.workspace = true
chrono.workspace = true
axum.workspace = true
sea-orm.workspace = true
tracing.workspace = true
thiserror.workspace = true
validator.workspace = true
utoipa.workspace = true
async-trait.workspace = true

View File

@@ -0,0 +1,95 @@
use chrono::{NaiveDate, NaiveTime};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateAppointmentReq {
pub patient_id: Uuid,
pub doctor_id: Option<Uuid>,
pub appointment_type: Option<String>,
pub appointment_date: NaiveDate,
pub start_time: NaiveTime,
pub end_time: NaiveTime,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateAppointmentStatusReq {
pub status: String,
pub cancel_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AppointmentResp {
pub id: Uuid,
pub patient_id: Uuid,
pub doctor_id: Option<Uuid>,
pub appointment_type: String,
pub appointment_date: NaiveDate,
pub start_time: NaiveTime,
pub end_time: NaiveTime,
pub status: String,
pub cancel_reason: Option<String>,
pub notes: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateScheduleReq {
pub doctor_id: Uuid,
pub schedule_date: NaiveDate,
pub period_type: Option<String>,
pub start_time: NaiveTime,
pub end_time: NaiveTime,
pub max_appointments: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateScheduleReq {
pub start_time: Option<NaiveTime>,
pub end_time: Option<NaiveTime>,
pub max_appointments: Option<i32>,
pub status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ScheduleResp {
pub id: Uuid,
pub doctor_id: Uuid,
pub schedule_date: NaiveDate,
pub period_type: String,
pub start_time: NaiveTime,
pub end_time: NaiveTime,
pub max_appointments: i32,
pub current_appointments: i32,
pub status: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
pub struct CalendarQuery {
pub start_date: Option<NaiveDate>,
pub end_date: Option<NaiveDate>,
pub doctor_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CalendarDayResp {
pub date: NaiveDate,
pub schedules: Vec<ScheduleResp>,
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
pub struct AppointmentListQuery {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub status: Option<String>,
pub patient_id: Option<Uuid>,
pub doctor_id: Option<Uuid>,
pub date: Option<NaiveDate>,
}

View File

@@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SessionResp {
pub id: Uuid,
pub patient_id: Uuid,
pub doctor_id: Option<Uuid>,
pub consultation_type: String,
pub status: String,
pub last_message_at: Option<chrono::DateTime<chrono::Utc>>,
pub unread_count_patient: i32,
pub unread_count_doctor: i32,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MessageResp {
pub id: Uuid,
pub session_id: Uuid,
pub sender_id: Uuid,
pub sender_role: String,
pub content_type: String,
pub content: String,
pub is_read: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateMessageReq {
pub session_id: Uuid,
pub sender_id: Uuid,
pub sender_role: String,
pub content_type: Option<String>,
pub content: String,
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
pub struct SessionQuery {
pub status: Option<String>,
pub patient_id: Option<Uuid>,
pub doctor_id: Option<Uuid>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}

View File

@@ -0,0 +1,123 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
/// 用 f64 替代 Decimal 以满足 utoipa ToSchema
type Decimal = f64;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateVitalSignsReq {
pub record_date: NaiveDate,
pub systolic_bp_morning: Option<i32>,
pub diastolic_bp_morning: Option<i32>,
pub systolic_bp_evening: Option<i32>,
pub diastolic_bp_evening: Option<i32>,
pub heart_rate: Option<i32>,
pub weight: Option<Decimal>,
pub blood_sugar: Option<Decimal>,
pub water_intake_ml: Option<i32>,
pub urine_output_ml: Option<i32>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateVitalSignsReq {
pub record_date: Option<NaiveDate>,
pub systolic_bp_morning: Option<i32>,
pub diastolic_bp_morning: Option<i32>,
pub systolic_bp_evening: Option<i32>,
pub diastolic_bp_evening: Option<i32>,
pub heart_rate: Option<i32>,
pub weight: Option<Decimal>,
pub blood_sugar: Option<Decimal>,
pub water_intake_ml: Option<i32>,
pub urine_output_ml: Option<i32>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct VitalSignsResp {
pub id: Uuid,
pub patient_id: Uuid,
pub record_date: NaiveDate,
pub systolic_bp_morning: Option<i32>,
pub diastolic_bp_morning: Option<i32>,
pub systolic_bp_evening: Option<i32>,
pub diastolic_bp_evening: Option<i32>,
pub heart_rate: Option<i32>,
pub weight: Option<Decimal>,
pub blood_sugar: Option<Decimal>,
pub water_intake_ml: Option<i32>,
pub urine_output_ml: Option<i32>,
pub notes: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateLabReportReq {
pub report_date: NaiveDate,
pub report_type: String,
pub indicators: Option<serde_json::Value>,
pub image_urls: Option<serde_json::Value>,
pub doctor_interpretation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct LabReportResp {
pub id: Uuid,
pub patient_id: Uuid,
pub report_date: NaiveDate,
pub report_type: String,
pub indicators: Option<serde_json::Value>,
pub image_urls: Option<serde_json::Value>,
pub doctor_interpretation: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateHealthRecordReq {
pub record_type: Option<String>,
pub record_date: NaiveDate,
pub source: Option<String>,
pub overall_assessment: Option<String>,
pub report_file_url: Option<String>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct HealthRecordResp {
pub id: Uuid,
pub patient_id: Uuid,
pub record_type: String,
pub record_date: NaiveDate,
pub source: Option<String>,
pub overall_assessment: Option<String>,
pub report_file_url: Option<String>,
pub notes: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TrendResp {
pub id: Uuid,
pub patient_id: Uuid,
pub period_start: NaiveDate,
pub period_end: NaiveDate,
pub indicator_summary: Option<serde_json::Value>,
pub abnormal_items: Option<serde_json::Value>,
pub generation_type: String,
pub report_file_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct IndicatorTimeseriesResp {
pub indicator: String,
pub data: Vec<(NaiveDate, f64)>,
}

View File

@@ -0,0 +1,4 @@
pub mod appointment_dto;
pub mod consultation_dto;
pub mod health_data_dto;
pub mod patient_dto;

View File

@@ -0,0 +1,93 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreatePatientReq {
pub name: String,
pub gender: Option<String>,
pub birth_date: Option<NaiveDate>,
pub blood_type: Option<String>,
pub id_number: Option<String>,
pub allergy_history: Option<String>,
pub medical_history_summary: Option<String>,
pub emergency_contact_name: Option<String>,
pub emergency_contact_phone: Option<String>,
pub source: Option<String>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdatePatientReq {
pub name: Option<String>,
pub gender: Option<String>,
pub birth_date: Option<NaiveDate>,
pub blood_type: Option<String>,
pub id_number: Option<String>,
pub allergy_history: Option<String>,
pub medical_history_summary: Option<String>,
pub emergency_contact_name: Option<String>,
pub emergency_contact_phone: Option<String>,
pub source: Option<String>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PatientResp {
pub id: Uuid,
pub user_id: Option<Uuid>,
pub name: String,
pub gender: Option<String>,
pub birth_date: Option<NaiveDate>,
pub blood_type: Option<String>,
pub id_number: Option<String>,
pub allergy_history: Option<String>,
pub medical_history_summary: Option<String>,
pub emergency_contact_name: Option<String>,
pub emergency_contact_phone: Option<String>,
pub status: String,
pub verification_status: String,
pub source: Option<String>,
pub notes: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct FamilyMemberReq {
pub name: String,
pub relationship: String,
pub phone: Option<String>,
pub birth_date: Option<NaiveDate>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct FamilyMemberResp {
pub id: Uuid,
pub patient_id: Uuid,
pub name: String,
pub relationship: String,
pub phone: Option<String>,
pub birth_date: Option<NaiveDate>,
pub notes: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ManageTagsReq {
pub tag_ids: Vec<Uuid>,
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
pub struct PatientListQuery {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub search: Option<String>,
pub tag_id: Option<Uuid>,
pub status: Option<String>,
}

View File

@@ -0,0 +1,55 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "appointment")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub doctor_id: Option<Uuid>,
pub appointment_type: String,
pub appointment_date: chrono::NaiveDate,
pub start_time: chrono::NaiveTime,
pub end_time: chrono::NaiveTime,
pub status: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub cancel_reason: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
#[sea_orm(
belongs_to = "super::doctor_profile::Entity",
from = "Column::DoctorId",
to = "super::doctor_profile::Column::Id"
)]
Doctor,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,43 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "consultation_message")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub session_id: Uuid,
pub sender_id: Uuid,
pub sender_role: String,
pub content_type: String,
pub content: String,
pub is_read: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::consultation_session::Entity",
from = "Column::SessionId",
to = "super::consultation_session::Column::Id"
)]
Session,
}
impl Related<super::consultation_session::Entity> for Entity {
fn to() -> RelationDef {
Relation::Session.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,61 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "consultation_session")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub doctor_id: Option<Uuid>,
#[sea_orm(rename = "type")]
pub consultation_type: String,
pub status: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub last_message_at: Option<DateTimeUtc>,
pub unread_count_patient: i32,
pub unread_count_doctor: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
#[sea_orm(
belongs_to = "super::doctor_profile::Entity",
from = "Column::DoctorId",
to = "super::doctor_profile::Column::Id"
)]
Doctor,
#[sea_orm(has_many = "super::consultation_message::Entity")]
Message,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl Related<super::consultation_message::Entity> for Entity {
fn to() -> RelationDef {
Relation::Message.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,54 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "doctor_profile")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub user_id: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub department: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub specialty: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub license_number: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
pub online_status: String,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::patient_doctor_relation::Entity")]
PatientRelation,
#[sea_orm(has_many = "super::doctor_schedule::Entity")]
Schedule,
}
impl Related<super::patient_doctor_relation::Entity> for Entity {
fn to() -> RelationDef {
Relation::PatientRelation.def()
}
}
impl Related<super::doctor_schedule::Entity> for Entity {
fn to() -> RelationDef {
Relation::Schedule.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,45 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "doctor_schedule")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub doctor_id: Uuid,
pub schedule_date: chrono::NaiveDate,
pub period_type: String,
pub start_time: chrono::NaiveTime,
pub end_time: chrono::NaiveTime,
pub max_appointments: i32,
pub current_appointments: i32,
pub status: String,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::doctor_profile::Entity",
from = "Column::DoctorId",
to = "super::doctor_profile::Column::Id"
)]
Doctor,
}
impl Related<super::doctor_profile::Entity> for Entity {
fn to() -> RelationDef {
Relation::Doctor.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,48 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "follow_up_record")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub task_id: Uuid,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub executed_by: Option<Uuid>,
pub executed_date: chrono::NaiveDate,
pub result: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub patient_condition: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub medical_advice: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub next_follow_up_date: Option<chrono::NaiveDate>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::follow_up_task::Entity",
from = "Column::TaskId",
to = "super::follow_up_task::Column::Id"
)]
Task,
}
impl Related<super::follow_up_task::Entity> for Entity {
fn to() -> RelationDef {
Relation::Task.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,55 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "follow_up_task")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub assigned_to: Option<Uuid>,
pub follow_up_type: String,
pub planned_date: chrono::NaiveDate,
pub status: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub content_template: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub related_appointment_id: Option<Uuid>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
#[sea_orm(has_many = "super::follow_up_record::Entity")]
Record,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl Related<super::follow_up_record::Entity> for Entity {
fn to() -> RelationDef {
Relation::Record.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,48 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "health_record")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
pub record_type: String,
pub record_date: chrono::NaiveDate,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub overall_assessment: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub report_file_url: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,47 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "health_trend")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
pub period_start: chrono::NaiveDate,
pub period_end: chrono::NaiveDate,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub indicator_summary: Option<serde_json::Value>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub abnormal_items: Option<serde_json::Value>,
pub generation_type: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub report_file_url: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,46 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "lab_report")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
pub report_date: chrono::NaiveDate,
pub report_type: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub indicators: Option<serde_json::Value>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub image_urls: Option<serde_json::Value>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub doctor_interpretation: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,16 @@
pub mod appointment;
pub mod consultation_message;
pub mod consultation_session;
pub mod doctor_profile;
pub mod doctor_schedule;
pub mod follow_up_record;
pub mod follow_up_task;
pub mod health_record;
pub mod health_trend;
pub mod lab_report;
pub mod patient;
pub mod patient_doctor_relation;
pub mod patient_family_member;
pub mod patient_tag;
pub mod patient_tag_relation;
pub mod vital_signs;

View File

@@ -0,0 +1,74 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "patient")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub user_id: Option<Uuid>,
pub name: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub birth_date: Option<chrono::NaiveDate>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub blood_type: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub id_number: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub allergy_history: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub medical_history_summary: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub emergency_contact_name: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub emergency_contact_phone: Option<String>,
pub status: String,
pub verification_status: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::patient_family_member::Entity")]
FamilyMember,
#[sea_orm(has_many = "super::patient_tag_relation::Entity")]
TagRelation,
#[sea_orm(has_many = "super::patient_doctor_relation::Entity")]
DoctorRelation,
#[sea_orm(has_many = "super::health_record::Entity")]
HealthRecord,
#[sea_orm(has_many = "super::vital_signs::Entity")]
VitalSigns,
#[sea_orm(has_many = "super::lab_report::Entity")]
LabReport,
#[sea_orm(has_many = "super::appointment::Entity")]
Appointment,
#[sea_orm(has_many = "super::follow_up_task::Entity")]
FollowUpTask,
#[sea_orm(has_many = "super::consultation_session::Entity")]
ConsultationSession,
}
impl Related<super::patient_family_member::Entity> for Entity {
fn to() -> RelationDef {
Relation::FamilyMember.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,51 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "patient_doctor_relation")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
pub doctor_id: Uuid,
pub relationship_type: String,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
#[sea_orm(
belongs_to = "super::doctor_profile::Entity",
from = "Column::DoctorId",
to = "super::doctor_profile::Column::Id"
)]
Doctor,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl Related<super::doctor_profile::Entity> for Entity {
fn to() -> RelationDef {
Relation::Doctor.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,46 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "patient_family_member")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
pub name: String,
pub relationship: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub birth_date: Option<chrono::NaiveDate>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,39 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "patient_tag")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub is_system: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::patient_tag_relation::Entity")]
TagRelation,
}
impl Related<super::patient_tag_relation::Entity> for Entity {
fn to() -> RelationDef {
Relation::TagRelation.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,50 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "patient_tag_relation")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
pub tag_id: Uuid,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
#[sea_orm(
belongs_to = "super::patient_tag::Entity",
from = "Column::TagId",
to = "super::patient_tag::Column::Id"
)]
Tag,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl Related<super::patient_tag::Entity> for Entity {
fn to() -> RelationDef {
Relation::Tag.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,59 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "vital_signs")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
pub record_date: chrono::NaiveDate,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub systolic_bp_morning: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub diastolic_bp_morning: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub systolic_bp_evening: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub diastolic_bp_evening: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub heart_rate: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub weight: Option<Decimal>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub blood_sugar: Option<Decimal>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub water_intake_ml: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub urine_output_ml: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,63 @@
use erp_core::error::AppError;
#[derive(Debug, thiserror::Error)]
pub enum HealthError {
#[error("{0}")]
Validation(String),
#[error("患者不存在")]
PatientNotFound,
#[error("医护档案不存在")]
DoctorNotFound,
#[error("预约不存在")]
AppointmentNotFound,
#[error("排班不存在")]
ScheduleNotFound,
#[error("排班已满,无法预约")]
ScheduleFull,
#[error("随访任务不存在")]
FollowUpTaskNotFound,
#[error("会话不存在")]
ConsultationNotFound,
#[error("状态转换无效: {0}")]
InvalidStatusTransition(String),
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
VersionMismatch,
#[error("数据库操作失败: {0}")]
DbError(String),
}
impl From<HealthError> for AppError {
fn from(err: HealthError) -> Self {
match err {
HealthError::Validation(s) => AppError::Validation(s),
HealthError::PatientNotFound
| HealthError::DoctorNotFound
| HealthError::AppointmentNotFound
| HealthError::ScheduleNotFound
| HealthError::FollowUpTaskNotFound
| HealthError::ConsultationNotFound => AppError::NotFound(err.to_string()),
HealthError::ScheduleFull => AppError::Validation(err.to_string()),
HealthError::InvalidStatusTransition(s) => AppError::Validation(s),
HealthError::VersionMismatch => AppError::VersionMismatch,
HealthError::DbError(_) => AppError::Internal(err.to_string()),
}
}
}
impl From<sea_orm::DbErr> for HealthError {
fn from(err: sea_orm::DbErr) -> Self {
HealthError::DbError(err.to_string())
}
}
pub type HealthResult<T> = Result<T, HealthError>;

View File

@@ -0,0 +1,7 @@
use erp_core::events::EventBus;
pub fn register_handlers(_bus: &EventBus) {
// Health 模块订阅的事件处理器
// - workflow.task.completed → 更新随访任务状态
// - message.sent → 联动咨询会话 last_message_at
}

View File

@@ -0,0 +1,226 @@
use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// DTO — 预约排班
// ---------------------------------------------------------------------------
/// 预约列表查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct AppointmentListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub patient_id: Option<Uuid>,
pub doctor_id: Option<Uuid>,
pub status: Option<String>,
pub date: Option<String>,
}
/// 创建预约请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateAppointmentReq {
pub patient_id: Uuid,
pub doctor_id: Uuid,
pub schedule_id: Uuid,
pub appointment_date: String,
pub start_time: String,
pub end_time: String,
pub reason: Option<String>,
}
/// 更新预约状态请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateAppointmentStatusReq {
pub status: String,
pub cancel_reason: Option<String>,
}
/// 预约响应
#[derive(Debug, Serialize, ToSchema)]
pub struct AppointmentResp {
pub id: Uuid,
pub patient_id: Uuid,
pub doctor_id: Uuid,
pub schedule_id: Uuid,
pub appointment_date: String,
pub start_time: String,
pub end_time: String,
pub status: String,
pub reason: Option<String>,
pub cancel_reason: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// 排班列表查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct ScheduleListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub doctor_id: Option<Uuid>,
pub start_date: Option<String>,
pub end_date: Option<String>,
}
/// 创建排班请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateScheduleReq {
pub doctor_id: Uuid,
pub schedule_date: String,
pub start_time: String,
pub end_time: String,
pub max_appointments: i32,
pub slot_duration_minutes: Option<i32>,
}
/// 更新排班请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateScheduleReq {
pub start_time: Option<String>,
pub end_time: Option<String>,
pub max_appointments: Option<i32>,
pub slot_duration_minutes: Option<i32>,
pub version: i32,
}
/// 排班响应
#[derive(Debug, Serialize, ToSchema)]
pub struct ScheduleResp {
pub id: Uuid,
pub doctor_id: Uuid,
pub schedule_date: String,
pub start_time: String,
pub end_time: String,
pub max_appointments: i32,
pub current_appointments: i32,
pub slot_duration_minutes: Option<i32>,
pub created_at: String,
pub updated_at: String,
}
/// 日历视图查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct CalendarViewParams {
pub doctor_id: Option<Uuid>,
pub start_date: String,
pub end_date: String,
}
/// 日历视图单个日期条目
#[derive(Debug, Serialize, ToSchema)]
pub struct CalendarDayEntry {
pub date: String,
pub schedules: Vec<ScheduleResp>,
pub appointments: Vec<AppointmentResp>,
}
/// 日历视图响应
#[derive(Debug, Serialize, ToSchema)]
pub struct CalendarViewResp {
pub days: Vec<CalendarDayEntry>,
}
// ---------------------------------------------------------------------------
// Handler — 预约排班 (7 个端点)
// ---------------------------------------------------------------------------
/// GET /api/v1/health/appointments — 预约列表
pub async fn list_appointments<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Query(_params): Query<AppointmentListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<AppointmentResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// POST /api/v1/health/appointments — 创建预约
pub async fn create_appointment<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Json(_req): Json<CreateAppointmentReq>,
) -> Result<Json<ApiResponse<AppointmentResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// PUT /api/v1/health/appointments/{id}/status — 更新预约状态
pub async fn update_appointment_status<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
Json(_req): Json<UpdateAppointmentStatusReq>,
) -> Result<Json<ApiResponse<AppointmentResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// GET /api/v1/health/schedules — 排班列表
pub async fn list_schedules<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Query(_params): Query<ScheduleListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<ScheduleResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// POST /api/v1/health/schedules — 创建排班
pub async fn create_schedule<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Json(_req): Json<CreateScheduleReq>,
) -> Result<Json<ApiResponse<ScheduleResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// PUT /api/v1/health/schedules/{id} — 更新排班
pub async fn update_schedule<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
Json(_req): Json<UpdateScheduleReq>,
) -> Result<Json<ApiResponse<ScheduleResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// GET /api/v1/health/calendar — 日历视图
pub async fn calendar_view<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Query(_params): Query<CalendarViewParams>,
) -> Result<Json<ApiResponse<CalendarViewResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}

View File

@@ -0,0 +1,142 @@
use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// DTO — 咨询管理
// ---------------------------------------------------------------------------
/// 会话列表查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct SessionListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub patient_id: Option<Uuid>,
pub doctor_id: Option<Uuid>,
pub status: Option<String>,
}
/// 会话响应
#[derive(Debug, Serialize, ToSchema)]
pub struct ConsultationSessionResp {
pub id: Uuid,
pub patient_id: Uuid,
pub doctor_id: Uuid,
pub subject: String,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
/// 消息列表查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct MessageListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
}
/// 创建消息请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateConsultationMessageReq {
pub content: String,
pub message_type: Option<String>,
}
/// 消息响应
#[derive(Debug, Serialize, ToSchema)]
pub struct ConsultationMessageResp {
pub id: Uuid,
pub session_id: Uuid,
pub sender_id: Uuid,
pub sender_type: String,
pub content: String,
pub message_type: String,
pub created_at: String,
}
/// 导出会话请求
#[derive(Debug, Deserialize, IntoParams)]
pub struct ExportSessionsParams {
pub patient_id: Option<Uuid>,
pub doctor_id: Option<Uuid>,
pub start_date: Option<String>,
pub end_date: Option<String>,
}
// ---------------------------------------------------------------------------
// Handler — 咨询管理 (5 个端点)
// ---------------------------------------------------------------------------
/// GET /api/v1/health/consultations/sessions — 会话列表
pub async fn list_sessions<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Query(_params): Query<SessionListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<ConsultationSessionResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// GET /api/v1/health/consultations/sessions/{id}/messages — 消息列表
pub async fn list_messages<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_session_id): Path<Uuid>,
Query(_params): Query<MessageListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<ConsultationMessageResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// PUT /api/v1/health/consultations/sessions/{id}/close — 关闭会话
pub async fn close_session<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
) -> Result<Json<ApiResponse<ConsultationSessionResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// POST /api/v1/health/consultations/sessions/{id}/messages — 创建消息
pub async fn create_message<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_session_id): Path<Uuid>,
Json(_req): Json<CreateConsultationMessageReq>,
) -> Result<Json<ApiResponse<ConsultationMessageResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// GET /api/v1/health/consultations/export — 导出会话
pub async fn export_sessions<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Query(_params): Query<ExportSessionsParams>,
) -> Result<Json<ApiResponse<Vec<ConsultationSessionResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}

View File

@@ -0,0 +1,136 @@
use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// DTO — 医护管理
// ---------------------------------------------------------------------------
/// 医护列表查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct DoctorListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
/// 按姓名模糊搜索
pub search: Option<String>,
/// 按科室筛选
pub department: Option<String>,
/// 按职称筛选
pub title: Option<String>,
}
/// 创建医护档案请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateDoctorReq {
pub user_id: Uuid,
pub name: String,
pub department: Option<String>,
pub title: Option<String>,
pub specialty: Option<String>,
pub license_number: Option<String>,
pub bio: Option<String>,
}
/// 更新医护档案请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateDoctorReq {
pub name: Option<String>,
pub department: Option<String>,
pub title: Option<String>,
pub specialty: Option<String>,
pub license_number: Option<String>,
pub bio: Option<String>,
pub version: i32,
}
/// 医护档案响应
#[derive(Debug, Serialize, ToSchema)]
pub struct DoctorResp {
pub id: Uuid,
pub user_id: Uuid,
pub name: String,
pub department: Option<String>,
pub title: Option<String>,
pub specialty: Option<String>,
pub license_number: Option<String>,
pub bio: Option<String>,
pub created_at: String,
pub updated_at: String,
}
// ---------------------------------------------------------------------------
// Handler — 医护管理 (5 个端点)
// ---------------------------------------------------------------------------
/// GET /api/v1/health/doctors — 医护档案列表
pub async fn list_doctors<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Query(_params): Query<DoctorListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<DoctorResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// POST /api/v1/health/doctors — 创建医护档案
pub async fn create_doctor<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Json(_req): Json<CreateDoctorReq>,
) -> Result<Json<ApiResponse<DoctorResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// GET /api/v1/health/doctors/{id} — 获取医护档案详情
pub async fn get_doctor<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
) -> Result<Json<ApiResponse<DoctorResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// PUT /api/v1/health/doctors/{id} — 更新医护档案
pub async fn update_doctor<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
Json(_req): Json<UpdateDoctorReq>,
) -> Result<Json<ApiResponse<DoctorResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// DELETE /api/v1/health/doctors/{id} — 删除医护档案
pub async fn delete_doctor<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}

View File

@@ -0,0 +1,178 @@
use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// DTO — 随访管理
// ---------------------------------------------------------------------------
/// 随访任务列表查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct FollowUpTaskListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub patient_id: Option<Uuid>,
pub assigned_to: Option<Uuid>,
pub status: Option<String>,
}
/// 创建随访任务请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateFollowUpTaskReq {
pub patient_id: Uuid,
pub task_type: String,
pub title: String,
pub description: Option<String>,
pub due_date: String,
pub assigned_to: Option<Uuid>,
}
/// 更新随访任务请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateFollowUpTaskReq {
pub task_type: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub due_date: Option<String>,
pub assigned_to: Option<Uuid>,
pub status: Option<String>,
pub version: i32,
}
/// 随访任务响应
#[derive(Debug, Serialize, ToSchema)]
pub struct FollowUpTaskResp {
pub id: Uuid,
pub patient_id: Uuid,
pub task_type: String,
pub title: String,
pub description: Option<String>,
pub due_date: String,
pub assigned_to: Option<Uuid>,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
/// 创建随访记录请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateFollowUpRecordReq {
pub task_id: Uuid,
pub contact_method: String,
pub content: String,
pub outcome: Option<String>,
pub next_follow_up_date: Option<String>,
}
/// 随访记录列表查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct FollowUpRecordListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub task_id: Option<Uuid>,
pub patient_id: Option<Uuid>,
}
/// 随访记录响应
#[derive(Debug, Serialize, ToSchema)]
pub struct FollowUpRecordResp {
pub id: Uuid,
pub task_id: Uuid,
pub patient_id: Uuid,
pub contact_method: String,
pub content: String,
pub outcome: Option<String>,
pub next_follow_up_date: Option<String>,
pub created_by: Uuid,
pub created_at: String,
}
// ---------------------------------------------------------------------------
// Handler — 随访管理 (6 个端点)
// ---------------------------------------------------------------------------
/// GET /api/v1/health/follow-up/tasks — 随访任务列表
pub async fn list_tasks<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Query(_params): Query<FollowUpTaskListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<FollowUpTaskResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// POST /api/v1/health/follow-up/tasks — 创建随访任务
pub async fn create_task<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Json(_req): Json<CreateFollowUpTaskReq>,
) -> Result<Json<ApiResponse<FollowUpTaskResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// PUT /api/v1/health/follow-up/tasks/{id} — 更新随访任务
pub async fn update_task<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
Json(_req): Json<UpdateFollowUpTaskReq>,
) -> Result<Json<ApiResponse<FollowUpTaskResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// DELETE /api/v1/health/follow-up/tasks/{id} — 删除随访任务
pub async fn delete_task<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// POST /api/v1/health/follow-up/records — 创建随访记录
pub async fn create_record<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Json(_req): Json<CreateFollowUpRecordReq>,
) -> Result<Json<ApiResponse<FollowUpRecordResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// GET /api/v1/health/follow-up/records — 随访记录列表
pub async fn list_records<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Query(_params): Query<FollowUpRecordListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<FollowUpRecordResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}

View File

@@ -0,0 +1,408 @@
use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// DTO — 健康数据
// ---------------------------------------------------------------------------
/// 生命体征列表查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct VitalSignsListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub patient_id: Uuid,
}
/// 创建生命体征请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateVitalSignsReq {
pub patient_id: Uuid,
pub blood_pressure_systolic: Option<i32>,
pub blood_pressure_diastolic: Option<i32>,
pub heart_rate: Option<i32>,
pub temperature: Option<f64>,
pub blood_oxygen: Option<i32>,
pub weight: Option<f64>,
pub height: Option<f64>,
pub measured_at: Option<String>,
pub notes: Option<String>,
}
/// 更新生命体征请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateVitalSignsReq {
pub blood_pressure_systolic: Option<i32>,
pub blood_pressure_diastolic: Option<i32>,
pub heart_rate: Option<i32>,
pub temperature: Option<f64>,
pub blood_oxygen: Option<i32>,
pub weight: Option<f64>,
pub height: Option<f64>,
pub measured_at: Option<String>,
pub notes: Option<String>,
pub version: i32,
}
/// 生命体征响应
#[derive(Debug, Serialize, ToSchema)]
pub struct VitalSignsResp {
pub id: Uuid,
pub patient_id: Uuid,
pub blood_pressure_systolic: Option<i32>,
pub blood_pressure_diastolic: Option<i32>,
pub heart_rate: Option<i32>,
pub temperature: Option<f64>,
pub blood_oxygen: Option<i32>,
pub weight: Option<f64>,
pub height: Option<f64>,
pub measured_at: Option<String>,
pub notes: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// 化验报告列表查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct LabReportListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub patient_id: Option<Uuid>,
}
/// 创建化验报告请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateLabReportReq {
pub patient_id: Uuid,
pub report_type: String,
pub report_date: String,
pub indicators: serde_json::Value,
pub file_url: Option<String>,
pub notes: Option<String>,
}
/// 更新化验报告请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateLabReportReq {
pub report_type: Option<String>,
pub report_date: Option<String>,
pub indicators: Option<serde_json::Value>,
pub file_url: Option<String>,
pub notes: Option<String>,
pub version: i32,
}
/// 化验报告响应
#[derive(Debug, Serialize, ToSchema)]
pub struct LabReportResp {
pub id: Uuid,
pub patient_id: Uuid,
pub report_type: String,
pub report_date: String,
pub indicators: serde_json::Value,
pub file_url: Option<String>,
pub notes: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// 健康档案列表查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct HealthRecordListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub patient_id: Option<Uuid>,
}
/// 创建健康档案请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateHealthRecordReq {
pub patient_id: Uuid,
pub record_type: String,
pub title: String,
pub content: serde_json::Value,
pub record_date: String,
}
/// 更新健康档案请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateHealthRecordReq {
pub record_type: Option<String>,
pub title: Option<String>,
pub content: Option<serde_json::Value>,
pub record_date: Option<String>,
pub version: i32,
}
/// 健康档案响应
#[derive(Debug, Serialize, ToSchema)]
pub struct HealthRecordResp {
pub id: Uuid,
pub patient_id: Uuid,
pub record_type: String,
pub title: String,
pub content: serde_json::Value,
pub record_date: String,
pub created_at: String,
pub updated_at: String,
}
/// 趋势分析列表查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct TrendListParams {
pub patient_id: Option<Uuid>,
}
/// 生成趋势请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct GenerateTrendReq {
pub patient_id: Uuid,
pub indicator_name: String,
pub start_date: String,
pub end_date: String,
}
/// 趋势分析响应
#[derive(Debug, Serialize, ToSchema)]
pub struct TrendResp {
pub id: Uuid,
pub patient_id: Uuid,
pub indicator_name: String,
pub trend_data: serde_json::Value,
pub analysis_summary: Option<String>,
pub generated_at: String,
}
/// 指标时间序列查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct IndicatorTimeseriesParams {
pub patient_id: Uuid,
pub indicator_name: String,
pub start_date: Option<String>,
pub end_date: Option<String>,
}
/// 指标时间序列数据点
#[derive(Debug, Serialize, ToSchema)]
pub struct TimeseriesDataPoint {
pub date: String,
pub value: f64,
pub unit: Option<String>,
}
/// 指标时间序列响应
#[derive(Debug, Serialize, ToSchema)]
pub struct IndicatorTimeseriesResp {
pub indicator_name: String,
pub patient_id: Uuid,
pub data_points: Vec<TimeseriesDataPoint>,
}
// ---------------------------------------------------------------------------
// Handler — 健康数据 (15 个端点)
// ---------------------------------------------------------------------------
/// GET /api/v1/health/vital-signs — 生命体征列表
pub async fn list_vital_signs<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Query(_params): Query<VitalSignsListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<VitalSignsResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// POST /api/v1/health/vital-signs — 创建生命体征记录
pub async fn create_vital_signs<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Json(_req): Json<CreateVitalSignsReq>,
) -> Result<Json<ApiResponse<VitalSignsResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// PUT /api/v1/health/vital-signs/{id} — 更新生命体征记录
pub async fn update_vital_signs<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
Json(_req): Json<UpdateVitalSignsReq>,
) -> Result<Json<ApiResponse<VitalSignsResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// DELETE /api/v1/health/vital-signs/{id} — 删除生命体征记录
pub async fn delete_vital_signs<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// GET /api/v1/health/lab-reports — 化验报告列表
pub async fn list_lab_reports<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Query(_params): Query<LabReportListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<LabReportResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// POST /api/v1/health/lab-reports — 创建化验报告
pub async fn create_lab_report<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Json(_req): Json<CreateLabReportReq>,
) -> Result<Json<ApiResponse<LabReportResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// PUT /api/v1/health/lab-reports/{id} — 更新化验报告
pub async fn update_lab_report<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
Json(_req): Json<UpdateLabReportReq>,
) -> Result<Json<ApiResponse<LabReportResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// DELETE /api/v1/health/lab-reports/{id} — 删除化验报告
pub async fn delete_lab_report<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// GET /api/v1/health/records — 健康档案列表
pub async fn list_health_records<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Query(_params): Query<HealthRecordListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<HealthRecordResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// POST /api/v1/health/records — 创建健康档案
pub async fn create_health_record<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Json(_req): Json<CreateHealthRecordReq>,
) -> Result<Json<ApiResponse<HealthRecordResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// PUT /api/v1/health/records/{id} — 更新健康档案
pub async fn update_health_record<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
Json(_req): Json<UpdateHealthRecordReq>,
) -> Result<Json<ApiResponse<HealthRecordResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// DELETE /api/v1/health/records/{id} — 删除健康档案
pub async fn delete_health_record<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// GET /api/v1/health/trends — 趋势分析列表
pub async fn list_trends<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Query(_params): Query<TrendListParams>,
) -> Result<Json<ApiResponse<Vec<TrendResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// POST /api/v1/health/trends/generate — 生成趋势分析
pub async fn generate_trend<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Json(_req): Json<GenerateTrendReq>,
) -> Result<Json<ApiResponse<TrendResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// GET /api/v1/health/trends/timeseries — 获取指标时间序列
pub async fn get_indicator_timeseries<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Query(_params): Query<IndicatorTimeseriesParams>,
) -> Result<Json<ApiResponse<IndicatorTimeseriesResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}

View File

@@ -0,0 +1,6 @@
pub mod appointment_handler;
pub mod consultation_handler;
pub mod doctor_handler;
pub mod follow_up_handler;
pub mod health_data_handler;
pub mod patient_handler;

View File

@@ -0,0 +1,311 @@
use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// DTO — 患者管理
// ---------------------------------------------------------------------------
/// 患者列表查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct PatientListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
/// 按姓名/身份证号模糊搜索
pub search: Option<String>,
/// 按标签筛选
pub tag_id: Option<Uuid>,
/// 按负责医生筛选
pub doctor_id: Option<Uuid>,
}
/// 创建患者请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreatePatientReq {
pub name: String,
pub id_card: Option<String>,
pub phone: Option<String>,
pub gender: Option<String>,
pub birth_date: Option<String>,
pub address: Option<String>,
pub emergency_contact: Option<String>,
pub emergency_phone: Option<String>,
pub medical_notes: Option<String>,
}
/// 更新患者请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdatePatientReq {
pub name: Option<String>,
pub id_card: Option<String>,
pub phone: Option<String>,
pub gender: Option<String>,
pub birth_date: Option<String>,
pub address: Option<String>,
pub emergency_contact: Option<String>,
pub emergency_phone: Option<String>,
pub medical_notes: Option<String>,
pub version: i32,
}
/// 患者标签管理请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct ManagePatientTagsReq {
pub tag_ids: Vec<Uuid>,
}
/// 患者响应
#[derive(Debug, Serialize, ToSchema)]
pub struct PatientResp {
pub id: Uuid,
pub name: String,
pub id_card: Option<String>,
pub phone: Option<String>,
pub gender: Option<String>,
pub birth_date: Option<String>,
pub address: Option<String>,
pub emergency_contact: Option<String>,
pub emergency_phone: Option<String>,
pub medical_notes: Option<String>,
pub tags: Vec<PatientTagResp>,
pub created_at: String,
pub updated_at: String,
}
/// 患者标签响应
#[derive(Debug, Serialize, ToSchema)]
pub struct PatientTagResp {
pub id: Uuid,
pub name: String,
pub color: Option<String>,
}
/// 健康摘要响应
#[derive(Debug, Serialize, ToSchema)]
pub struct HealthSummaryResp {
pub patient_id: Uuid,
pub latest_vital_signs: Option<serde_json::Value>,
pub latest_lab_report: Option<serde_json::Value>,
pub upcoming_appointments: u64,
pub pending_follow_ups: u64,
}
/// 家庭成员请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateFamilyMemberReq {
pub name: String,
pub relationship: String,
pub phone: Option<String>,
pub id_card: Option<String>,
}
/// 更新家庭成员请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateFamilyMemberReq {
pub name: Option<String>,
pub relationship: Option<String>,
pub phone: Option<String>,
pub id_card: Option<String>,
}
/// 家庭成员响应
#[derive(Debug, Serialize, ToSchema)]
pub struct FamilyMemberResp {
pub id: Uuid,
pub patient_id: Uuid,
pub name: String,
pub relationship: String,
pub phone: Option<String>,
pub id_card: Option<String>,
}
/// 分配医生请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct AssignDoctorReq {
pub doctor_id: Uuid,
}
// ---------------------------------------------------------------------------
// Handler — 患者管理 (13 个端点)
// ---------------------------------------------------------------------------
/// GET /api/v1/health/patients — 患者列表
pub async fn list_patients<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Query(_params): Query<PatientListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<PatientResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// POST /api/v1/health/patients — 创建患者
pub async fn create_patient<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Json(_req): Json<CreatePatientReq>,
) -> Result<Json<ApiResponse<PatientResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// GET /api/v1/health/patients/{id} — 获取患者详情
pub async fn get_patient<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
) -> Result<Json<ApiResponse<PatientResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// PUT /api/v1/health/patients/{id} — 更新患者
pub async fn update_patient<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
Json(_req): Json<UpdatePatientReq>,
) -> Result<Json<ApiResponse<PatientResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// DELETE /api/v1/health/patients/{id} — 删除患者(软删除)
pub async fn delete_patient<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// POST /api/v1/health/patients/{id}/tags — 管理患者标签
pub async fn manage_patient_tags<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
Json(_req): Json<ManagePatientTagsReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// GET /api/v1/health/patients/{id}/health-summary — 获取患者健康摘要
pub async fn get_health_summary<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
) -> Result<Json<ApiResponse<HealthSummaryResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// GET /api/v1/health/patients/{id}/family-members — 家庭成员列表
pub async fn list_family_members<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
) -> Result<Json<ApiResponse<Vec<FamilyMemberResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// POST /api/v1/health/patients/{id}/family-members — 创建家庭成员
pub async fn create_family_member<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
Json(_req): Json<CreateFamilyMemberReq>,
) -> Result<Json<ApiResponse<FamilyMemberResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// PUT /api/v1/health/patients/{patient_id}/family-members/{member_id} — 更新家庭成员
pub async fn update_family_member<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path((_patient_id, _member_id)): Path<(Uuid, Uuid)>,
Json(_req): Json<UpdateFamilyMemberReq>,
) -> Result<Json<ApiResponse<FamilyMemberResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// DELETE /api/v1/health/patients/{patient_id}/family-members/{member_id} — 删除家庭成员
pub async fn delete_family_member<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path((_patient_id, _member_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// POST /api/v1/health/patients/{id}/doctors — 分配负责医生
pub async fn assign_doctor<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path(_id): Path<Uuid>,
Json(_req): Json<AssignDoctorReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}
/// DELETE /api/v1/health/patients/{patient_id}/doctors/{doctor_id} — 移除负责医生
pub async fn remove_doctor<S>(
State(_state): State<HealthState>,
Extension(_ctx): Extension<TenantContext>,
Path((_patient_id, _doctor_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Err(AppError::Internal("Not implemented yet".into()))
}

View File

@@ -0,0 +1,11 @@
pub mod dto;
pub mod entity;
pub mod error;
pub mod event;
pub mod handler;
pub mod module;
pub mod service;
pub mod state;
pub use module::HealthModule;
pub use state::HealthState;

View File

@@ -0,0 +1,322 @@
use axum::Router;
use uuid::Uuid;
use erp_core::error::AppResult;
use erp_core::events::EventBus;
use erp_core::module::{ErpModule, PermissionDescriptor};
use crate::handler::{
appointment_handler, consultation_handler, doctor_handler, follow_up_handler,
health_data_handler, patient_handler,
};
pub struct HealthModule;
impl HealthModule {
pub fn new() -> Self {
Self
}
pub fn public_routes<S>() -> Router<S>
where
crate::state::HealthState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
}
pub fn protected_routes<S>() -> Router<S>
where
crate::state::HealthState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
// 患者管理
.route(
"/health/patients",
axum::routing::get(patient_handler::list_patients)
.post(patient_handler::create_patient),
)
.route(
"/health/patients/{id}",
axum::routing::get(patient_handler::get_patient)
.put(patient_handler::update_patient)
.delete(patient_handler::delete_patient),
)
.route(
"/health/patients/{id}/tags",
axum::routing::post(patient_handler::manage_patient_tags),
)
.route(
"/health/patients/{id}/health-summary",
axum::routing::get(patient_handler::get_health_summary),
)
.route(
"/health/patients/{id}/family-members",
axum::routing::get(patient_handler::list_family_members)
.post(patient_handler::create_family_member),
)
.route(
"/health/patients/{id}/family-members/{fid}",
axum::routing::put(patient_handler::update_family_member)
.delete(patient_handler::delete_family_member),
)
.route(
"/health/patients/{id}/doctors",
axum::routing::post(patient_handler::assign_doctor),
)
.route(
"/health/patients/{id}/doctors/{did}",
axum::routing::delete(patient_handler::remove_doctor),
)
// 健康数据
.route(
"/health/patients/{id}/vital-signs",
axum::routing::get(health_data_handler::list_vital_signs)
.post(health_data_handler::create_vital_signs),
)
.route(
"/health/patients/{id}/vital-signs/{vid}",
axum::routing::put(health_data_handler::update_vital_signs)
.delete(health_data_handler::delete_vital_signs),
)
.route(
"/health/patients/{id}/lab-reports",
axum::routing::get(health_data_handler::list_lab_reports)
.post(health_data_handler::create_lab_report),
)
.route(
"/health/patients/{id}/lab-reports/{rid}",
axum::routing::put(health_data_handler::update_lab_report)
.delete(health_data_handler::delete_lab_report),
)
.route(
"/health/patients/{id}/health-records",
axum::routing::get(health_data_handler::list_health_records)
.post(health_data_handler::create_health_record),
)
.route(
"/health/patients/{id}/health-records/{rid}",
axum::routing::put(health_data_handler::update_health_record)
.delete(health_data_handler::delete_health_record),
)
.route(
"/health/patients/{id}/trends",
axum::routing::get(health_data_handler::list_trends),
)
.route(
"/health/patients/{id}/trends/generate",
axum::routing::post(health_data_handler::generate_trend),
)
.route(
"/health/patients/{id}/trends/{indicator}",
axum::routing::get(health_data_handler::get_indicator_timeseries),
)
// 预约排班
.route(
"/health/appointments",
axum::routing::get(appointment_handler::list_appointments)
.post(appointment_handler::create_appointment),
)
.route(
"/health/appointments/{id}/status",
axum::routing::put(appointment_handler::update_appointment_status),
)
.route(
"/health/doctor-schedules",
axum::routing::get(appointment_handler::list_schedules)
.post(appointment_handler::create_schedule),
)
.route(
"/health/doctor-schedules/{id}",
axum::routing::put(appointment_handler::update_schedule),
)
.route(
"/health/doctor-schedules/calendar",
axum::routing::get(appointment_handler::calendar_view),
)
// 随访管理
.route(
"/health/follow-up-tasks",
axum::routing::get(follow_up_handler::list_tasks)
.post(follow_up_handler::create_task),
)
.route(
"/health/follow-up-tasks/{id}",
axum::routing::put(follow_up_handler::update_task)
.delete(follow_up_handler::delete_task),
)
.route(
"/health/follow-up-tasks/{id}/records",
axum::routing::post(follow_up_handler::create_record),
)
.route(
"/health/follow-up-records",
axum::routing::get(follow_up_handler::list_records),
)
// 咨询管理
.route(
"/health/consultation-sessions",
axum::routing::get(consultation_handler::list_sessions),
)
.route(
"/health/consultation-sessions/{id}/messages",
axum::routing::get(consultation_handler::list_messages),
)
.route(
"/health/consultation-sessions/{id}/close",
axum::routing::put(consultation_handler::close_session),
)
.route(
"/health/consultation-messages",
axum::routing::post(consultation_handler::create_message),
)
.route(
"/health/consultation-sessions/export",
axum::routing::get(consultation_handler::export_sessions),
)
// 医护管理
.route(
"/health/doctors",
axum::routing::get(doctor_handler::list_doctors)
.post(doctor_handler::create_doctor),
)
.route(
"/health/doctors/{id}",
axum::routing::get(doctor_handler::get_doctor)
.put(doctor_handler::update_doctor)
.delete(doctor_handler::delete_doctor),
)
}
}
impl Default for HealthModule {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl ErpModule for HealthModule {
fn name(&self) -> &str {
"health"
}
fn version(&self) -> &str {
env!("CARGO_PKG_VERSION")
}
fn dependencies(&self) -> Vec<&str> {
vec!["auth"]
}
fn register_event_handlers(&self, bus: &EventBus) {
crate::event::register_handlers(bus);
}
async fn on_tenant_created(
&self,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
_event_bus: &EventBus,
) -> AppResult<()> {
crate::service::seed::seed_tenant_health(db, tenant_id)
.await
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
tracing::info!(tenant_id = %tenant_id, "Health module tenant initialized");
Ok(())
}
async fn on_tenant_deleted(
&self,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AppResult<()> {
crate::service::seed::soft_delete_tenant_data(db, tenant_id)
.await
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
tracing::info!(tenant_id = %tenant_id, "Health module tenant data soft-deleted");
Ok(())
}
fn permissions(&self) -> Vec<PermissionDescriptor> {
vec![
PermissionDescriptor {
code: "health.patient.list".into(),
name: "查看患者列表".into(),
description: "查看和搜索患者列表、详情".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.patient.manage".into(),
name: "管理患者".into(),
description: "创建、编辑、删除患者".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.health-data.list".into(),
name: "查看健康数据".into(),
description: "查看体检记录、监测数据、化验报告".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.health-data.manage".into(),
name: "管理健康数据".into(),
description: "录入、编辑、删除健康数据".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.appointment.list".into(),
name: "查看预约".into(),
description: "查看预约列表和排班".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.appointment.manage".into(),
name: "管理预约".into(),
description: "创建、确认、取消预约".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.follow-up.list".into(),
name: "查看随访".into(),
description: "查看随访任务和记录".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.follow-up.manage".into(),
name: "管理随访".into(),
description: "创建、分配、完成随访任务".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.consultation.list".into(),
name: "查看咨询".into(),
description: "查看咨询会话和消息记录".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.consultation.manage".into(),
name: "管理咨询".into(),
description: "关闭会话、导出记录".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.doctor.list".into(),
name: "查看医护".into(),
description: "查看医护列表和详情".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.doctor.manage".into(),
name: "管理医护".into(),
description: "创建、编辑医护档案、排班".into(),
module: "health".into(),
},
]
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@@ -0,0 +1,120 @@
//! 预约排班 Service — 预约CRUD、排班管理、日历视图、原子CAS预约
use chrono::NaiveDate;
use uuid::Uuid;
use erp_core::types::{PaginatedResponse, Pagination};
use crate::dto::appointment_dto::{
AppointmentResp, CalendarDayResp, CreateAppointmentReq, CreateScheduleReq,
ScheduleResp, UpdateAppointmentStatusReq, UpdateScheduleReq,
};
use crate::error::HealthResult;
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// 预约管理 (Appointments)
// ---------------------------------------------------------------------------
/// 预约列表(分页 + 多条件筛选)
pub async fn list_appointments(
state: &HealthState,
tenant_id: Uuid,
pagination: Pagination,
status: Option<String>,
patient_id: Option<Uuid>,
doctor_id: Option<Uuid>,
date: Option<NaiveDate>,
) -> HealthResult<PaginatedResponse<AppointmentResp>> {
let _ = (state, tenant_id, pagination, status, patient_id, doctor_id, date);
todo!()
}
/// 创建预约(原子 CAS 占位,防止超额预约)
pub async fn create_appointment(
state: &HealthState,
tenant_id: Uuid,
req: CreateAppointmentReq,
user_id: Option<Uuid>,
) -> HealthResult<AppointmentResp> {
let _ = (state, tenant_id, req, user_id);
// 实现时需要:
// 1. 查找对应排班档位
// 2. 原子 CAS: UPDATE doctor_schedule SET current_appointments = current_appointments + 1
// WHERE id = ? AND current_appointments < max_appointments
// 3. CAS 失败返回 ScheduleFull 错误
// 4. 创建预约记录
// 5. 发布 appointment.created 事件
todo!()
}
/// 更新预约状态(确认/取消/完成/未到)
pub async fn update_appointment_status(
state: &HealthState,
tenant_id: Uuid,
appointment_id: Uuid,
req: UpdateAppointmentStatusReq,
version: i32,
) -> HealthResult<AppointmentResp> {
let _ = (state, tenant_id, appointment_id, req, version);
// 实现时需要:
// 1. 状态机校验pending -> confirmed/cancelled, confirmed -> completed/no_show/cancelled
// 2. 取消时释放排班名额(原子减 1
// 3. 发布 appointment.confirmed / appointment.cancelled 事件
todo!()
}
// ---------------------------------------------------------------------------
// 排班管理 (Doctor Schedules)
// ---------------------------------------------------------------------------
/// 排班列表
pub async fn list_schedules(
state: &HealthState,
tenant_id: Uuid,
pagination: Pagination,
doctor_id: Option<Uuid>,
date: Option<NaiveDate>,
) -> HealthResult<PaginatedResponse<ScheduleResp>> {
let _ = (state, tenant_id, pagination, doctor_id, date);
todo!()
}
/// 创建排班
pub async fn create_schedule(
state: &HealthState,
tenant_id: Uuid,
req: CreateScheduleReq,
user_id: Option<Uuid>,
) -> HealthResult<ScheduleResp> {
let _ = (state, tenant_id, req, user_id);
todo!()
}
/// 更新排班(乐观锁)
pub async fn update_schedule(
state: &HealthState,
tenant_id: Uuid,
schedule_id: Uuid,
req: UpdateScheduleReq,
version: i32,
) -> HealthResult<ScheduleResp> {
let _ = (state, tenant_id, schedule_id, req, version);
todo!()
}
// ---------------------------------------------------------------------------
// 日历视图
// ---------------------------------------------------------------------------
/// 日历视图(按日期范围返回每天的排班汇总)
pub async fn calendar_view(
state: &HealthState,
tenant_id: Uuid,
start_date: NaiveDate,
end_date: NaiveDate,
doctor_id: Option<Uuid>,
) -> HealthResult<Vec<CalendarDayResp>> {
let _ = (state, tenant_id, start_date, end_date, doctor_id);
todo!()
}

View File

@@ -0,0 +1,83 @@
//! 咨询管理 Service — 会话管理、消息收发、会话关闭、导出
use uuid::Uuid;
use erp_core::types::{PaginatedResponse, Pagination};
use crate::dto::consultation_dto::{
CreateMessageReq, MessageResp, SessionQuery, SessionResp,
};
use crate::error::HealthResult;
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// 咨询会话 (Consultation Sessions)
// ---------------------------------------------------------------------------
/// 咨询会话列表(分页 + 多条件筛选)
pub async fn list_sessions(
state: &HealthState,
tenant_id: Uuid,
query: SessionQuery,
) -> HealthResult<PaginatedResponse<SessionResp>> {
let _ = (state, tenant_id, query);
todo!()
}
/// 关闭咨询会话
pub async fn close_session(
state: &HealthState,
tenant_id: Uuid,
session_id: Uuid,
version: i32,
) -> HealthResult<SessionResp> {
let _ = (state, tenant_id, session_id, version);
// 实现时需要:
// 1. 校验会话存在且状态为 active
// 2. 更新状态为 closed
// 3. 发布 consultation.closed 事件
todo!()
}
/// 导出咨询会话(按条件筛选后返回汇总数据)
pub async fn export_sessions(
state: &HealthState,
tenant_id: Uuid,
status: Option<String>,
patient_id: Option<Uuid>,
doctor_id: Option<Uuid>,
) -> HealthResult<Vec<SessionResp>> {
let _ = (state, tenant_id, status, patient_id, doctor_id);
todo!()
}
// ---------------------------------------------------------------------------
// 咨询消息 (Consultation Messages)
// ---------------------------------------------------------------------------
/// 消息列表(按会话 ID 查询,分页)
pub async fn list_messages(
state: &HealthState,
tenant_id: Uuid,
session_id: Uuid,
pagination: Pagination,
) -> HealthResult<PaginatedResponse<MessageResp>> {
let _ = (state, tenant_id, session_id, pagination);
todo!()
}
/// 发送消息
pub async fn create_message(
state: &HealthState,
tenant_id: Uuid,
req: CreateMessageReq,
) -> HealthResult<MessageResp> {
let _ = (state, tenant_id, req);
// 实现时需要:
// 1. 校验会话存在且状态为 active
// 2. 创建消息记录
// 3. 更新会话的 last_message_at
// 4. 根据发送者角色更新对方的 unread_count
// 5. 发布 consultation.message.created 事件
todo!()
}

View File

@@ -0,0 +1,161 @@
//! 随访管理 Service — 随访任务CRUD、随访记录、状态流转
use chrono::NaiveDate;
use uuid::Uuid;
use erp_core::types::{PaginatedResponse, Pagination};
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// 随访任务 DTO内部使用follow_up_dto 尚未创建独立文件)
// ---------------------------------------------------------------------------
/// 创建随访任务请求
#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)]
pub struct CreateFollowUpTaskReq {
pub patient_id: Uuid,
pub assigned_to: Option<Uuid>,
pub follow_up_type: String,
pub planned_date: NaiveDate,
pub content_template: Option<String>,
pub related_appointment_id: Option<Uuid>,
}
/// 更新随访任务请求
#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)]
pub struct UpdateFollowUpTaskReq {
pub assigned_to: Option<Uuid>,
pub follow_up_type: Option<String>,
pub planned_date: Option<NaiveDate>,
pub content_template: Option<String>,
pub status: Option<String>,
}
/// 随访任务响应
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
pub struct FollowUpTaskResp {
pub id: Uuid,
pub patient_id: Uuid,
pub assigned_to: Option<Uuid>,
pub follow_up_type: String,
pub planned_date: NaiveDate,
pub status: String,
pub content_template: Option<String>,
pub related_appointment_id: Option<Uuid>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
/// 创建随访记录请求
#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)]
pub struct CreateFollowUpRecordReq {
pub task_id: Uuid,
pub executed_by: Option<Uuid>,
pub executed_date: NaiveDate,
pub result: String,
pub patient_condition: Option<String>,
pub medical_advice: Option<String>,
pub next_follow_up_date: Option<NaiveDate>,
}
/// 随访记录响应
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
pub struct FollowUpRecordResp {
pub id: Uuid,
pub task_id: Uuid,
pub executed_by: Option<Uuid>,
pub executed_date: NaiveDate,
pub result: String,
pub patient_condition: Option<String>,
pub medical_advice: Option<String>,
pub next_follow_up_date: Option<NaiveDate>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
// ---------------------------------------------------------------------------
// 随访任务
// ---------------------------------------------------------------------------
/// 随访任务列表(分页 + 多条件筛选)
pub async fn list_tasks(
state: &HealthState,
tenant_id: Uuid,
pagination: Pagination,
patient_id: Option<Uuid>,
assigned_to: Option<Uuid>,
status: Option<String>,
) -> HealthResult<PaginatedResponse<FollowUpTaskResp>> {
let _ = (state, tenant_id, pagination, patient_id, assigned_to, status);
todo!()
}
/// 创建随访任务
pub async fn create_task(
state: &HealthState,
tenant_id: Uuid,
req: CreateFollowUpTaskReq,
user_id: Option<Uuid>,
) -> HealthResult<FollowUpTaskResp> {
let _ = (state, tenant_id, req, user_id);
todo!()
}
/// 更新随访任务(乐观锁)
pub async fn update_task(
state: &HealthState,
tenant_id: Uuid,
task_id: Uuid,
req: UpdateFollowUpTaskReq,
version: i32,
) -> HealthResult<FollowUpTaskResp> {
let _ = (state, tenant_id, task_id, req, version);
todo!()
}
/// 删除随访任务(软删除)
pub async fn delete_task(
state: &HealthState,
tenant_id: Uuid,
task_id: Uuid,
) -> HealthResult<()> {
let _ = (state, tenant_id, task_id);
todo!()
}
// ---------------------------------------------------------------------------
// 随访记录
// ---------------------------------------------------------------------------
/// 创建随访执行记录(同时将任务状态推进为 completed
pub async fn create_record(
state: &HealthState,
tenant_id: Uuid,
req: CreateFollowUpRecordReq,
user_id: Option<Uuid>,
) -> HealthResult<FollowUpRecordResp> {
let _ = (state, tenant_id, req, user_id);
// 实现时需要:
// 1. 校验任务存在且状态为 in_progress / pending
// 2. 创建随访记录
// 3. 更新任务状态为 completed
// 4. 如果设置了 next_follow_up_date自动创建下一个随访任务
// 5. 发布 follow_up.completed 事件
todo!()
}
/// 随访记录列表(分页)
pub async fn list_records(
state: &HealthState,
tenant_id: Uuid,
pagination: Pagination,
task_id: Option<Uuid>,
patient_id: Option<Uuid>,
) -> HealthResult<PaginatedResponse<FollowUpRecordResp>> {
let _ = (state, tenant_id, pagination, task_id, patient_id);
todo!()
}

View File

@@ -0,0 +1,207 @@
//! 健康数据 Service — 体征记录、化验报告、体检记录、趋势分析
use chrono::NaiveDate;
use uuid::Uuid;
use erp_core::types::{PaginatedResponse, Pagination};
use crate::dto::health_data_dto::{
CreateHealthRecordReq, CreateLabReportReq, CreateVitalSignsReq, HealthRecordResp,
IndicatorTimeseriesResp, LabReportResp, TrendResp, UpdateVitalSignsReq,
};
use crate::error::HealthResult;
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// 体征记录 (Vital Signs)
// ---------------------------------------------------------------------------
/// 体征记录列表
pub async fn list_vital_signs(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
pagination: Pagination,
) -> HealthResult<PaginatedResponse<crate::dto::health_data_dto::VitalSignsResp>> {
let _ = (state, tenant_id, patient_id, pagination);
todo!()
}
/// 创建体征记录
pub async fn create_vital_signs(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
req: CreateVitalSignsReq,
user_id: Option<Uuid>,
) -> HealthResult<crate::dto::health_data_dto::VitalSignsResp> {
let _ = (state, tenant_id, patient_id, req, user_id);
todo!()
}
/// 更新体征记录(乐观锁)
pub async fn update_vital_signs(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
vital_signs_id: Uuid,
req: UpdateVitalSignsReq,
version: i32,
) -> HealthResult<crate::dto::health_data_dto::VitalSignsResp> {
let _ = (state, tenant_id, patient_id, vital_signs_id, req, version);
todo!()
}
/// 删除体征记录
pub async fn delete_vital_signs(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
vital_signs_id: Uuid,
) -> HealthResult<()> {
let _ = (state, tenant_id, patient_id, vital_signs_id);
todo!()
}
// ---------------------------------------------------------------------------
// 化验报告 (Lab Reports)
// ---------------------------------------------------------------------------
/// 化验报告列表
pub async fn list_lab_reports(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
pagination: Pagination,
) -> HealthResult<PaginatedResponse<LabReportResp>> {
let _ = (state, tenant_id, patient_id, pagination);
todo!()
}
/// 创建化验报告
pub async fn create_lab_report(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
req: CreateLabReportReq,
user_id: Option<Uuid>,
) -> HealthResult<LabReportResp> {
let _ = (state, tenant_id, patient_id, req, user_id);
todo!()
}
/// 更新化验报告(乐观锁)
pub async fn update_lab_report(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
report_id: Uuid,
req: CreateLabReportReq,
version: i32,
) -> HealthResult<LabReportResp> {
let _ = (state, tenant_id, patient_id, report_id, req, version);
todo!()
}
/// 删除化验报告
pub async fn delete_lab_report(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
report_id: Uuid,
) -> HealthResult<()> {
let _ = (state, tenant_id, patient_id, report_id);
todo!()
}
// ---------------------------------------------------------------------------
// 体检记录 (Health Records)
// ---------------------------------------------------------------------------
/// 体检记录列表
pub async fn list_health_records(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
pagination: Pagination,
) -> HealthResult<PaginatedResponse<HealthRecordResp>> {
let _ = (state, tenant_id, patient_id, pagination);
todo!()
}
/// 创建体检记录
pub async fn create_health_record(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
req: CreateHealthRecordReq,
user_id: Option<Uuid>,
) -> HealthResult<HealthRecordResp> {
let _ = (state, tenant_id, patient_id, req, user_id);
todo!()
}
/// 更新体检记录(乐观锁)
pub async fn update_health_record(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
record_id: Uuid,
req: CreateHealthRecordReq,
version: i32,
) -> HealthResult<HealthRecordResp> {
let _ = (state, tenant_id, patient_id, record_id, req, version);
todo!()
}
/// 删除体检记录
pub async fn delete_health_record(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
record_id: Uuid,
) -> HealthResult<()> {
let _ = (state, tenant_id, patient_id, record_id);
todo!()
}
// ---------------------------------------------------------------------------
// 趋势分析 (Trends)
// ---------------------------------------------------------------------------
/// 趋势列表
pub async fn list_trends(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
pagination: Pagination,
) -> HealthResult<PaginatedResponse<TrendResp>> {
let _ = (state, tenant_id, patient_id, pagination);
todo!()
}
/// 生成趋势分析报告(基于历史体征 + 化验数据聚合)
pub async fn generate_trend(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
period_start: NaiveDate,
period_end: NaiveDate,
user_id: Option<Uuid>,
) -> HealthResult<TrendResp> {
let _ = (state, tenant_id, patient_id, period_start, period_end, user_id);
todo!()
}
/// 获取单个指标的时间序列数据
pub async fn get_indicator_timeseries(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
indicator: String,
start_date: Option<NaiveDate>,
end_date: Option<NaiveDate>,
) -> HealthResult<IndicatorTimeseriesResp> {
let _ = (state, tenant_id, patient_id, indicator, start_date, end_date);
todo!()
}

View File

@@ -0,0 +1,6 @@
pub mod appointment_service;
pub mod consultation_service;
pub mod follow_up_service;
pub mod health_data_service;
pub mod patient_service;
pub mod seed;

View File

@@ -0,0 +1,179 @@
//! 患者管理 Service — 患者CRUD、家庭成员、标签、医生关联、健康摘要
use uuid::Uuid;
use erp_core::types::{PaginatedResponse, Pagination};
use crate::dto::patient_dto::{
CreatePatientReq, FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp,
UpdatePatientReq,
};
use crate::error::HealthResult;
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// 患者 CRUD
// ---------------------------------------------------------------------------
/// 患者列表(分页 + 搜索 + 标签筛选)
pub async fn list_patients(
state: &HealthState,
tenant_id: Uuid,
pagination: Pagination,
search: Option<String>,
tag_id: Option<Uuid>,
) -> HealthResult<PaginatedResponse<PatientResp>> {
let _ = (state, tenant_id, pagination, search, tag_id);
todo!()
}
/// 创建患者
pub async fn create_patient(
state: &HealthState,
tenant_id: Uuid,
user_id: Option<Uuid>,
req: CreatePatientReq,
) -> HealthResult<PatientResp> {
let _ = (state, tenant_id, user_id, req);
todo!()
}
/// 获取患者详情
pub async fn get_patient(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
) -> HealthResult<PatientResp> {
let _ = (state, tenant_id, id);
todo!()
}
/// 更新患者信息(乐观锁)
pub async fn update_patient(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
req: UpdatePatientReq,
version: i32,
) -> HealthResult<PatientResp> {
let _ = (state, tenant_id, id, req, version);
todo!()
}
/// 软删除患者
pub async fn delete_patient(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
) -> HealthResult<()> {
let _ = (state, tenant_id, id);
todo!()
}
// ---------------------------------------------------------------------------
// 标签管理
// ---------------------------------------------------------------------------
/// 管理患者标签(覆盖式:传入的 tag_ids 替换当前关联)
pub async fn manage_patient_tags(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
req: ManageTagsReq,
user_id: Option<Uuid>,
) -> HealthResult<()> {
let _ = (state, tenant_id, patient_id, req, user_id);
todo!()
}
// ---------------------------------------------------------------------------
// 健康摘要
// ---------------------------------------------------------------------------
/// 获取患者健康摘要(最新体征 + 最新化验 + 待处理预约 + 待办随访)
pub async fn get_health_summary(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
) -> HealthResult<serde_json::Value> {
let _ = (state, tenant_id, patient_id);
todo!()
}
// ---------------------------------------------------------------------------
// 家庭成员
// ---------------------------------------------------------------------------
/// 家庭成员列表
pub async fn list_family_members(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
) -> HealthResult<Vec<FamilyMemberResp>> {
let _ = (state, tenant_id, patient_id);
todo!()
}
/// 创建家庭成员
pub async fn create_family_member(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
req: FamilyMemberReq,
user_id: Option<Uuid>,
) -> HealthResult<FamilyMemberResp> {
let _ = (state, tenant_id, patient_id, req, user_id);
todo!()
}
/// 更新家庭成员(乐观锁)
pub async fn update_family_member(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
family_member_id: Uuid,
req: FamilyMemberReq,
version: i32,
) -> HealthResult<FamilyMemberResp> {
let _ = (state, tenant_id, patient_id, family_member_id, req, version);
todo!()
}
/// 删除家庭成员
pub async fn delete_family_member(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
family_member_id: Uuid,
) -> HealthResult<()> {
let _ = (state, tenant_id, patient_id, family_member_id);
todo!()
}
// ---------------------------------------------------------------------------
// 患者-医生关联
// ---------------------------------------------------------------------------
/// 分配负责医生
pub async fn assign_doctor(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
doctor_id: Uuid,
relationship_type: String,
user_id: Option<Uuid>,
) -> HealthResult<()> {
let _ = (state, tenant_id, patient_id, doctor_id, relationship_type, user_id);
todo!()
}
/// 移除负责医生
pub async fn remove_doctor(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
doctor_id: Uuid,
) -> HealthResult<()> {
let _ = (state, tenant_id, patient_id, doctor_id);
todo!()
}

View File

@@ -0,0 +1,22 @@
//! 租户初始化种子数据 — 创建默认标签、默认排班模板等
use sea_orm::DatabaseConnection;
use uuid::Uuid;
/// 初始化租户健康模块默认数据
pub async fn seed_tenant_health(
_db: &DatabaseConnection,
tenant_id: Uuid,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
tracing::info!(tenant_id = %tenant_id, "Seeding health module default data");
Ok(())
}
/// 软删除该租户下所有健康模块数据
pub async fn soft_delete_tenant_data(
_db: &DatabaseConnection,
tenant_id: Uuid,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
tracing::info!(tenant_id = %tenant_id, "Soft-deleting health module data for tenant");
Ok(())
}

View File

@@ -0,0 +1,8 @@
use erp_core::events::EventBus;
use sea_orm::DatabaseConnection;
#[derive(Clone)]
pub struct HealthState {
pub db: DatabaseConnection,
pub event_bus: EventBus,
}

View File

@@ -136,11 +136,13 @@ is_public = true
name = "phone"
field_type = "string"
display_name = "电话"
validation = { pattern = "^1[3-9]\\d{9}$", message = "请输入有效的手机号" }
[[schema.entities.fields]]
name = "email"
field_type = "string"
display_name = "邮箱"
validation = { pattern = "^[^@]+@[^@]+\\.[^@]+$", message = "请输入有效的邮箱地址" }
[[schema.entities.fields]]
name = "industry"
@@ -359,6 +361,7 @@ display_name = "报价单"
field_type = "decimal"
display_name = "总金额"
sortable = true
visible_when = "status != 'draft'"
[[schema.entities.fields]]
name = "notes"
@@ -452,12 +455,16 @@ display_name = "合同"
field_type = "uuid"
display_name = "关联商机"
ref_entity = "opportunity"
cascade_from = "client_id"
cascade_filter = "client_id"
[[schema.entities.fields]]
name = "quote_id"
field_type = "uuid"
display_name = "关联报价"
ref_entity = "quote"
cascade_from = "client_id"
cascade_filter = "client_id"
[[schema.entities.fields]]
name = "contract_number"
@@ -518,6 +525,7 @@ display_name = "合同"
field_type = "decimal"
display_name = "已付金额"
default = 0
visible_when = "status != 'drafting'"
[[schema.entities.fields]]
name = "start_date"
@@ -728,6 +736,7 @@ display_name = "任务"
name = "actual_hours"
field_type = "decimal"
display_name = "实际工时"
visible_when = "status != 'todo'"
[[schema.entities.fields]]
name = "description"
@@ -749,6 +758,8 @@ display_name = "工时记录"
ref_entity = "task"
ref_label_field = "title"
ref_search_fields = ["title"]
cascade_from = "project_id"
cascade_filter = "project_id"
[[schema.entities.fields]]
name = "project_id"
@@ -798,12 +809,16 @@ display_name = "发票/收款"
field_type = "uuid"
display_name = "关联项目"
ref_entity = "project"
cascade_from = "client_id"
cascade_filter = "client_id"
[[schema.entities.fields]]
name = "contract_id"
field_type = "uuid"
display_name = "关联合同"
ref_entity = "contract"
cascade_from = "client_id"
cascade_filter = "client_id"
[[schema.entities.fields]]
name = "invoice_number"
@@ -861,6 +876,7 @@ display_name = "发票/收款"
name = "payment_date"
field_type = "date"
display_name = "实际收款日期"
visible_when = "status == 'paid' || status == 'partial'"
[[schema.entities.fields]]
name = "notes"
@@ -920,6 +936,98 @@ display_name = "支出"
field_type = "string"
display_name = "描述"
# ── 插件配置 ──
[settings]
[[settings.fields]]
name = "company_name"
display_name = "公司名称"
field_type = "text"
required = true
group = "基本信息"
[[settings.fields]]
name = "currency_symbol"
display_name = "货币符号"
field_type = "text"
default_value = "¥"
group = "基本信息"
[[settings.fields]]
name = "default_tax_rate"
display_name = "默认税率(%)"
field_type = "number"
default_value = 6
range = [0.0, 100.0]
group = "财务"
[[settings.fields]]
name = "payment_reminder_days"
display_name = "收款提前提醒(天)"
field_type = "number"
default_value = 3
range = [1.0, 30.0]
group = "提醒"
[[settings.fields]]
name = "notify_contract_expiring"
display_name = "合同到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_payment_overdue"
display_name = "逾期收款提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_opportunity_followup"
display_name = "商机跟进提醒"
field_type = "boolean"
default_value = true
group = "提醒"
# ── 触发事件 ──
[[trigger_events]]
name = "opportunity_stage_changed"
display_name = "商机阶段变更"
description = "商机阶段发生变化时通知,特别是成交或失败"
entity = "opportunity"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "contract"
on = "update"
[[trigger_events]]
name = "invoice_status_changed"
display_name = "发票状态变更"
description = "发票状态变化时检查逾期收款"
entity = "invoice"
on = "update"
[[trigger_events]]
name = "task_status_changed"
display_name = "任务状态变更"
description = "任务完成或取消时通知"
entity = "task"
on = "update"
[[trigger_events]]
name = "expense_created"
display_name = "新支出记录"
description = "记录新支出时通知"
entity = "expense"
on = "create"
# ── 编号规则 ──
[[numbering]]
@@ -943,6 +1051,26 @@ prefix = "INV"
format = "{PREFIX}-{YEAR}-{SEQ}"
seq_length = 4
# ── 打印模板 ──
[[templates]]
name = "quote_pdf"
display_name = "报价单"
entity = "quote"
format = "pdf"
[[templates]]
name = "invoice_pdf"
display_name = "发票"
entity = "invoice"
format = "pdf"
[[templates]]
name = "contract_pdf"
display_name = "合同"
entity = "contract"
format = "pdf"
# ── 页面设计 ──
# 页面 1全局工作台
@@ -951,6 +1079,49 @@ type = "dashboard"
label = "工作台"
icon = "DashboardOutlined"
# ── 财务概览卡片 ──
[[ui.pages.widgets]]
type = "stat_cards"
label = "财务概览"
cards = [
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "type == 'payment' && status != 'overdue'", label = "本月收入", icon = "rise", color = "green" },
{ entity = "expense", aggregate = "sum", field = "amount", label = "本月支出", icon = "fall", color = "red" },
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "status == 'overdue' || status == 'pending'", label = "应收总额", icon = "dollar", color = "orange" },
{ entity = "invoice", aggregate = "count", filter = "status == 'overdue'", label = "逾期笔数", icon = "warning", color = "red" }
]
# ── 紧急待办 ──
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
queries = [
{ entity = "invoice", filter = "status == 'overdue'", label_field = "invoice_number", subtitle_field = "amount", action = "查看", icon = "warning" },
{ entity = "task", filter = "status != 'done' && status != 'cancelled'", sort = "due_date asc", label_field = "title", subtitle_field = "due_date", action = "处理", icon = "clock" },
{ entity = "contract", filter = "status == 'active'", sort = "end_date asc", label_field = "title", subtitle_field = "end_date", action = "续约", icon = "file-text" },
{ entity = "opportunity", filter = "next_follow_up <= today", label_field = "title", subtitle_field = "next_follow_up", action = "跟进", icon = "phone" }
]
# ── 商机漏斗 ──
[[ui.pages.widgets]]
type = "funnel"
label = "商机漏斗"
entity = "opportunity"
lane_field = "stage"
value_field = "estimated_amount"
lane_order = ["visit", "requirement", "quote", "negotiation", "won", "lost"]
# ── 活跃项目卡片 ──
[[ui.pages.widgets]]
type = "card_list"
label = "活跃项目"
entity = "project"
filter = "status == 'in_progress'"
max_items = 4
title_field = "name"
subtitle_field = "contract_amount"
tags = ["business_type", "status"]
# 页面 2客户管理列表 + 详情 + 商机看板)
[[ui.pages]]
type = "tabs"

View File

@@ -76,6 +76,7 @@ display_name = "维保合同"
required = true
display_name = "合同编号"
unique = true
validation = { pattern = "^SC-\\d{4}-\\d{4}$", message = "格式SC-YYYY-NNNN" }
[[schema.entities.fields]]
name = "name"
@@ -190,6 +191,8 @@ display_name = "工单"
ref_entity = "service_contract"
ref_label_field = "name"
ref_search_fields = ["name"]
cascade_from = "client_id"
cascade_filter = "client_id"
[[schema.entities.fields]]
name = "client_id"
@@ -279,21 +282,25 @@ display_name = "工单"
field_type = "string"
display_name = "解决方案"
ui_widget = "textarea"
visible_when = "status == 'resolved' || status == 'closed'"
[[schema.entities.fields]]
name = "responded_at"
field_type = "date_time"
display_name = "首次响应时间"
visible_when = "status != 'open'"
[[schema.entities.fields]]
name = "resolved_at"
field_type = "date_time"
display_name = "解决时间"
visible_when = "status == 'resolved' || status == 'closed'"
[[schema.entities.fields]]
name = "closed_at"
field_type = "date_time"
display_name = "关闭时间"
visible_when = "status == 'closed'"
# ── 3.3.3 check_plan巡检计划──
@@ -399,6 +406,8 @@ display_name = "巡检记录"
field_type = "uuid"
display_name = "维保合同"
ref_entity = "service_contract"
cascade_from = "plan_id"
cascade_filter = "contract_id"
[[schema.entities.fields]]
name = "client_id"
@@ -439,12 +448,14 @@ display_name = "巡检记录"
field_type = "string"
display_name = "发现的问题"
ui_widget = "textarea"
visible_when = "result == 'abnormal'"
[[schema.entities.fields]]
name = "actions_taken"
field_type = "string"
display_name = "采取措施"
ui_widget = "textarea"
visible_when = "result == 'abnormal'"
[[schema.entities.fields]]
name = "notes"
@@ -452,6 +463,70 @@ display_name = "巡检记录"
display_name = "备注"
ui_widget = "textarea"
# ── 插件配置 ──
[settings]
[[settings.fields]]
name = "default_sla_response"
display_name = "默认SLA响应时间(小时)"
field_type = "number"
default_value = 8
range = [1.0, 72.0]
group = "SLA"
[[settings.fields]]
name = "default_sla_resolve"
display_name = "默认SLA解决时间(小时)"
field_type = "number"
default_value = 48
range = [1.0, 168.0]
group = "SLA"
[[settings.fields]]
name = "notify_sla_breach"
display_name = "SLA超标提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_check_due"
display_name = "巡检到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
# ── 触发事件 ──
[[trigger_events]]
name = "ticket_created"
display_name = "新工单"
description = "创建工单时开始SLA计时并通知"
entity = "ticket"
on = "create"
[[trigger_events]]
name = "ticket_status_changed"
display_name = "工单状态变更"
description = "工单状态变化时检查SLA是否达标"
entity = "ticket"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "维保合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "service_contract"
on = "update"
[[trigger_events]]
name = "check_plan_updated"
display_name = "巡检计划更新"
description = "巡检计划更新时检查下次巡检日期"
entity = "check_plan"
on = "update"
# ── 编号规则 ──
[[numbering]]
@@ -461,8 +536,42 @@ prefix = "SC"
format = "{PREFIX}-{YEAR}-{SEQ}"
seq_length = 4
# ── 打印模板 ──
[[templates]]
name = "service_contract_pdf"
display_name = "维保合同"
entity = "service_contract"
format = "pdf"
# ── 页面设计 ──
# 页面 0运维概览仪表盘
[[ui.pages]]
type = "dashboard"
label = "运维概览"
icon = "DashboardOutlined"
[[ui.pages.widgets]]
type = "stat_cards"
label = "运维概览"
cards = [
{ entity = "service_contract", aggregate = "count", filter = "status == 'active'", label = "活跃合同", icon = "file-text", color = "blue" },
{ entity = "ticket", aggregate = "count", filter = "status == 'open' || status == 'in_progress'", label = "待处理工单", icon = "tool", color = "orange" },
{ entity = "ticket", aggregate = "count", filter = "status == 'resolved'", label = "已解决工单", icon = "check-circle", color = "green" },
{ entity = "check_plan", aggregate = "count", filter = "status == 'active'", label = "活跃巡检", icon = "schedule", color = "blue" }
]
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
queries = [
{ entity = "ticket", filter = "status == 'open'", sort = "priority asc", label_field = "title", subtitle_field = "type", action = "处理", icon = "warning" },
{ entity = "service_contract", filter = "status == 'active'", sort = "end_date asc", label_field = "name", subtitle_field = "end_date", action = "续约", icon = "file-text" },
{ entity = "check_plan", filter = "status == 'active'", sort = "next_check_date asc", label_field = "name", subtitle_field = "next_check_date", action = "巡检", icon = "schedule" }
]
# 页面 1合同管理 + 详情
[[ui.pages]]
type = "crud"

View File

@@ -284,6 +284,8 @@ pub enum PluginPageType {
label: String,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
widgets: Vec<PluginWidget>,
},
#[serde(rename = "kanban")]
Kanban {
@@ -304,6 +306,80 @@ pub enum PluginPageType {
},
}
/// Dashboard Widget 类型
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PluginWidget {
#[serde(rename = "stat_cards")]
StatCards {
label: String,
cards: Vec<StatCard>,
},
#[serde(rename = "action_list")]
ActionList {
label: String,
#[serde(default)]
max_items: Option<u32>,
queries: Vec<ActionQuery>,
},
#[serde(rename = "funnel")]
Funnel {
label: String,
entity: String,
lane_field: String,
#[serde(default)]
value_field: Option<String>,
lane_order: Vec<String>,
},
#[serde(rename = "card_list")]
CardList {
label: String,
entity: String,
#[serde(default)]
filter: Option<String>,
#[serde(default)]
max_items: Option<u32>,
title_field: String,
#[serde(default)]
subtitle_field: Option<String>,
#[serde(default)]
tags: Vec<String>,
},
}
/// 统计卡片
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct StatCard {
pub entity: String,
#[serde(default)]
pub aggregate: Option<String>,
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub filter: Option<String>,
pub label: String,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub color: Option<String>,
}
/// 待办行动查询
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ActionQuery {
pub entity: String,
#[serde(default)]
pub filter: Option<String>,
#[serde(default)]
pub sort: Option<String>,
pub label_field: String,
#[serde(default)]
pub subtitle_field: Option<String>,
pub action: String,
#[serde(default)]
pub icon: Option<String>,
}
/// 插件页面区段(用于 detail 页面类型)
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
@@ -1553,4 +1629,153 @@ name = "管理发票"
assert_eq!(entities[0].importable, Some(true));
assert_eq!(entities[0].exportable, Some(true));
}
#[test]
fn parse_dashboard_with_widgets() {
let toml = r##"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "invoice"
display_name = "发票"
[[schema.entities.fields]]
name = "status"
field_type = "string"
display_name = "状态"
[[schema.entities.fields]]
name = "amount"
field_type = "decimal"
display_name = "金额"
[ui]
[[ui.pages]]
type = "dashboard"
label = "工作台"
icon = "DashboardOutlined"
[[ui.pages.widgets]]
type = "stat_cards"
label = "财务概览"
[[ui.pages.widgets.cards]]
entity = "invoice"
aggregate = "count"
label = "总发票"
icon = "FileTextOutlined"
color = "#1890ff"
[[ui.pages.widgets.cards]]
entity = "invoice"
aggregate = "sum"
field = "amount"
filter = "status == 'pending'"
label = "待收金额"
icon = "DollarOutlined"
color = "#faad14"
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
[[ui.pages.widgets.queries]]
entity = "invoice"
filter = "status == 'overdue'"
sort = "due_date asc"
label_field = "invoice_number"
subtitle_field = "amount"
action = "open_invoice"
icon = "warning"
[[ui.pages.widgets]]
type = "funnel"
label = "商机漏斗"
entity = "invoice"
lane_field = "status"
value_field = "amount"
lane_order = ["pending", "issued", "paid"]
[[ui.pages.widgets]]
type = "card_list"
label = "活跃项目"
entity = "invoice"
filter = "status == 'active'"
max_items = 10
title_field = "invoice_number"
subtitle_field = "amount"
tags = ["status"]
"##;
let manifest = parse_manifest(toml).unwrap();
let ui = manifest.ui.unwrap();
assert_eq!(ui.pages.len(), 1);
match &ui.pages[0] {
PluginPageType::Dashboard {
label, icon, widgets,
} => {
assert_eq!(label, "工作台");
assert_eq!(icon.as_deref(), Some("DashboardOutlined"));
assert_eq!(widgets.len(), 4);
// stat_cards
match &widgets[0] {
PluginWidget::StatCards { label, cards } => {
assert_eq!(label, "财务概览");
assert_eq!(cards.len(), 2);
assert_eq!(cards[0].entity, "invoice");
assert_eq!(cards[0].aggregate.as_deref(), Some("count"));
assert_eq!(cards[1].aggregate.as_deref(), Some("sum"));
assert_eq!(cards[1].filter.as_deref(), Some("status == 'pending'"));
}
_ => panic!("Expected StatCards"),
}
// action_list
match &widgets[1] {
PluginWidget::ActionList {
label, max_items, queries,
} => {
assert_eq!(label, "紧急待办");
assert_eq!(*max_items, Some(5));
assert_eq!(queries.len(), 1);
assert_eq!(queries[0].entity, "invoice");
assert_eq!(queries[0].action, "open_invoice");
}
_ => panic!("Expected ActionList"),
}
// funnel
match &widgets[2] {
PluginWidget::Funnel {
label, entity, lane_field, value_field, lane_order,
} => {
assert_eq!(label, "商机漏斗");
assert_eq!(entity, "invoice");
assert_eq!(lane_field, "status");
assert_eq!(value_field.as_deref(), Some("amount"));
assert_eq!(lane_order, &["pending", "issued", "paid"]);
}
_ => panic!("Expected Funnel"),
}
// card_list
match &widgets[3] {
PluginWidget::CardList {
label, entity, title_field, ..
} => {
assert_eq!(label, "活跃项目");
assert_eq!(entity, "invoice");
assert_eq!(title_field, "invoice_number");
}
_ => panic!("Expected CardList"),
}
}
_ => panic!("Expected Dashboard page type"),
}
}
}

View File

@@ -27,6 +27,7 @@ erp-config.workspace = true
erp-workflow.workspace = true
erp-message.workspace = true
erp-plugin.workspace = true
erp-health.workspace = true
anyhow.workspace = true
uuid.workspace = true
chrono.workspace = true

View File

@@ -41,6 +41,7 @@ mod m20260419_000038_fix_crm_permission_codes;
mod m20260419_000039_entity_registry_columns;
mod m20260419_000040_plugin_market;
mod m20260419_000041_plugin_user_views;
mod m20260423_000042_create_health_tables;
pub struct Migrator;
@@ -89,6 +90,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260419_000039_entity_registry_columns::Migration),
Box::new(m20260419_000040_plugin_market::Migration),
Box::new(m20260419_000041_plugin_user_views::Migration),
Box::new(m20260423_000042_create_health_tables::Migration),
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -319,12 +319,21 @@ async fn main() -> anyhow::Result<()> {
"Message module initialized"
);
// Initialize health module
let health_module = erp_health::HealthModule::new();
tracing::info!(
module = health_module.name(),
version = health_module.version(),
"Health module initialized"
);
// Initialize module registry and register modules
let registry = ModuleRegistry::new()
.register(auth_module)
.register(config_module)
.register(workflow_module)
.register(message_module);
.register(message_module)
.register(health_module);
tracing::info!(
module_count = registry.modules().len(),
"Modules registered"
@@ -431,6 +440,7 @@ async fn main() -> anyhow::Result<()> {
.merge(erp_workflow::WorkflowModule::protected_routes())
.merge(erp_message::MessageModule::protected_routes())
.merge(erp_plugin::module::PluginModule::protected_routes())
.merge(erp_health::HealthModule::protected_routes())
.merge(handlers::audit_log::audit_log_router())
.layer(axum::middleware::from_fn_with_state(
state.clone(),

View File

@@ -96,3 +96,13 @@ impl FromRef<AppState> for erp_plugin::state::PluginState {
}
}
}
/// Allow erp-health handlers to extract their required state.
impl FromRef<AppState> for erp_health::HealthState {
fn from_ref(state: &AppState) -> Self {
Self {
db: state.db.clone(),
event_bus: state.event_bus.clone(),
}
}
}

View File

@@ -0,0 +1,587 @@
# freelance + itops 插件增强实施计划
> 日期: 2026-04-20
> 对应规格: `docs/superpowers/specs/2026-04-20-freelance-itops-plugin-enhancement-design.md`
> 前置: 两插件已部署freelance 10 实体/20 权限itops 4 实体/8 权限)
---
## 总览
| Phase | 内容 | 类型 | 依赖 |
|-------|------|------|------|
| P1 | freelance Layer 1 — 智能业务引擎 | 纯 plugin.toml | 无 |
| P2 | itops Layer 1 — 智能业务引擎 | 纯 plugin.toml | 无 |
| P3 | freelance Layer 3 — PDF 模板 | 纯 plugin.toml | 无 |
| P4 | itops Layer 3 — PDF 模板 | 纯 plugin.toml | 无 |
| P5 | 平台 dashboard widgets 扩展 | manifest.rs + 前端 | P1-P4 完成 |
| P6 | freelance + itops Layer 2 — 仪表盘 | plugin.toml + 前端 | P5 完成 |
P1-P4 可并行P5-P6 顺序依赖。
---
## Phase 1: freelance Layer 1 — 智能业务引擎
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
### Task 1.1: 新增 `[settings]` 段落
`[[numbering]]` 之前插入 7 个配置项:
```toml
[settings]
[[settings.fields]]
name = "company_name"
display_name = "公司名称"
field_type = "text"
required = true
group = "基本信息"
[[settings.fields]]
name = "currency_symbol"
display_name = "货币符号"
field_type = "text"
default_value = "¥"
group = "基本信息"
[[settings.fields]]
name = "default_tax_rate"
display_name = "默认税率(%)"
field_type = "number"
default_value = 6
range = [0.0, 100.0]
group = "财务"
[[settings.fields]]
name = "payment_reminder_days"
display_name = "收款提前提醒(天)"
field_type = "number"
default_value = 3
range = [1.0, 30.0]
group = "提醒"
[[settings.fields]]
name = "notify_contract_expiring"
display_name = "合同到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_payment_overdue"
display_name = "逾期收款提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_opportunity_followup"
display_name = "商机跟进提醒"
field_type = "boolean"
default_value = true
group = "提醒"
```
### Task 1.2: 新增 `[[trigger_events]]` 段落
`[settings]` 之后插入 5 个触发事件:
```toml
[[trigger_events]]
name = "opportunity_stage_changed"
display_name = "商机阶段变更"
description = "商机阶段发生变化时通知,特别是成交或失败"
entity = "opportunity"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "contract"
on = "update"
[[trigger_events]]
name = "invoice_status_changed"
display_name = "发票状态变更"
description = "发票状态变化时检查逾期收款"
entity = "invoice"
on = "update"
[[trigger_events]]
name = "task_status_changed"
display_name = "任务状态变更"
description = "任务完成或取消时通知"
entity = "task"
on = "update"
[[trigger_events]]
name = "expense_created"
display_name = "新支出记录"
description = "记录新支出时通知"
entity = "expense"
on = "create"
```
### Task 1.3: 追加 cascade 属性5 处已有字段)
**1.3a** contract.opportunity_id第 450-454 行)追加:
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**1.3b** contract.quote_id第 456-460 行)追加:
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**1.3c** invoice.project_id第 796-800 行)追加:
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**1.3d** invoice.contract_id第 802-806 行)追加:
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**1.3e** time_entry.task_id第 745-751 行)追加:
```toml
cascade_from = "project_id"
cascade_filter = "project_id"
```
### Task 1.4: 追加 visible_when 属性4 处已有字段)
**1.4a** invoice.payment_date第 860-863 行)追加:
```toml
visible_when = "status == 'paid' || status == 'partial'"
```
**1.4b** contract.paid_amount第 516-520 行)追加:
```toml
visible_when = "status != 'drafting'"
```
**1.4c** task.actual_hours第 727-730 行)追加:
```toml
visible_when = "status != 'todo'"
```
**1.4d** quote.total_amount第 357-361 行)追加:
```toml
visible_when = "status != 'draft'"
```
### Task 1.5: 追加 validation 属性2 处已有字段)
**1.5a** client.phone第 135-138 行)追加:
```toml
validation = { pattern = "^1[3-9]\\d{9}$", message = "请输入有效的手机号" }
```
**1.5b** client.email第 140-143 行)追加:
```toml
validation = { pattern = "^[^@]+@[^@]+\\.[^@]+$", message = "请输入有效的邮箱地址" }
```
### Task 1.6: 编译 WASM + 升级插件
```bash
cargo build -p erp-plugin-freelance --target wasm32-unknown-unknown --release
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_freelance.wasm -o target/erp_plugin_freelance.component.wasm
```
通过 API 升级:
```bash
# 上传新版本 WASM
curl -X POST http://localhost:3000/api/v1/admin/plugins/{plugin_id}/upgrade \
-H "Authorization: Bearer {token}" \
-F "wasm=@target/erp_plugin_freelance.component.wasm" \
-F "manifest=@crates/erp-plugin-freelance/plugin.toml"
```
### Task 1.7: 验证
- [ ] `cargo check` 通过
- [ ] 重新登录获取新 JWT权限可能变化
- [ ] 前端打开 freelance 插件 → 设置页面可见 7 个配置项
- [ ] 创建客户 → phone 格式错误时提示校验信息
- [ ] 创建客户 → email 格式错误时提示校验信息
- [ ] 创建合同 → 选客户后 opportunity_id 和 quote_id 自动过滤
- [ ] 创建发票 → 选客户后 project_id 和 contract_id 自动过滤
- [ ] 创建工时 → 选项目后 task_id 自动过滤
- [ ] invoice 状态为 pending 时payment_date 字段不显示
- [ ] contract 状态为 drafting 时paid_amount 字段不显示
- [ ] 触发事件:更新商机阶段 → 消息中心收到通知
- [ ] `git add && git commit && git push`
---
## Phase 2: itops Layer 1 — 智能业务引擎
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
### Task 2.1: 新增 `[settings]` 段落
`[[numbering]]` 之前插入 4 个配置项:
```toml
[settings]
[[settings.fields]]
name = "default_sla_response"
display_name = "默认SLA响应时间(小时)"
field_type = "number"
default_value = 8
range = [1.0, 72.0]
group = "SLA"
[[settings.fields]]
name = "default_sla_resolve"
display_name = "默认SLA解决时间(小时)"
field_type = "number"
default_value = 48
range = [1.0, 168.0]
group = "SLA"
[[settings.fields]]
name = "notify_sla_breach"
display_name = "SLA超标提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_check_due"
display_name = "巡检到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
```
### Task 2.2: 新增 `[[trigger_events]]` 段落
`[settings]` 之后插入 4 个触发事件:
```toml
[[trigger_events]]
name = "ticket_created"
display_name = "新工单"
description = "创建工单时开始SLA计时并通知"
entity = "ticket"
on = "create"
[[trigger_events]]
name = "ticket_status_changed"
display_name = "工单状态变更"
description = "工单状态变化时检查SLA是否达标"
entity = "ticket"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "维保合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "service_contract"
on = "update"
[[trigger_events]]
name = "check_plan_updated"
display_name = "巡检计划更新"
description = "巡检计划更新时检查下次巡检日期"
entity = "check_plan"
on = "update"
```
### Task 2.3: 追加 cascade 属性2 处已有字段)
**2.3a** ticket.contract_id第 186-192 行)追加:
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**2.3b** check_record.contract_id第 398-400 行)追加:
```toml
cascade_from = "plan_id"
cascade_filter = "contract_id"
```
### Task 2.4: 追加 visible_when 属性6 处已有字段)
**2.4a** ticket.resolution → `visible_when = "status == 'resolved' || status == 'closed'"`
**2.4b** ticket.responded_at → `visible_when = "status != 'open'"`
**2.4c** ticket.resolved_at → `visible_when = "status == 'resolved' || status == 'closed'"`
**2.4d** ticket.closed_at → `visible_when = "status == 'closed'"`
**2.4e** check_record.issues_found → `visible_when = "result == 'abnormal'"`
**2.4f** check_record.actions_taken → `visible_when = "result == 'abnormal'"`
### Task 2.5: 追加 validation 属性1 处已有字段)
**2.5a** service_contract.contract_number第 73-78 行)追加:
```toml
validation = { pattern = "^SC-\\d{4}-\\d{4}$", message = "格式SC-YYYY-NNNN" }
```
### Task 2.6: 编译 WASM + 升级插件
```bash
cargo build -p erp-plugin-itops --target wasm32-unknown-unknown --release
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_itops.wasm -o target/erp_plugin_itops.component.wasm
```
### Task 2.7: 验证
- [ ] `cargo check` 通过
- [ ] 重新登录获取新 JWT
- [ ] 前端打开 itops 插件 → 设置页面可见 4 个配置项
- [ ] 创建工单 → 选客户后 contract_id 自动过滤
- [ ] 工单状态为 open 时resolution/resolved_at/closed_at 不显示
- [ ] 工单状态改为 resolved → resolution 和 resolved_at 出现
- [ ] 巡检记录结果为 normal → issues_found/actions_taken 不显示
- [ ] 巡检记录结果改为 abnormal → issues_found/actions_taken 出现
- [ ] 触发事件:创建工单 → 消息中心收到通知
- [ ] `git add && git commit && git push`
---
## Phase 3: freelance Layer 3 — PDF 模板
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
### Task 3.1: 新增 `[[templates]]` 段落3 个模板)
`[[ui.pages]]` 之前插入报价单、发票、合同 3 个 PDF 模板。
**报价单模板** (`quote_pdf`)
- entity = "quote"
- 包含 Handlebars 语法:`{{quote_number}}`, `{{client.name}}`, `{{#each lines}}`
- 表格渲染item_name / description / quantity / unit_price / amount
- 底部subtotal / tax_rate / total_amount
**发票模板** (`invoice_pdf`)
- entity = "invoice"
- grid 布局client.name / type / issue_date / due_date
- 大字金额:`¥{{amount}}`
- 状态 badge
**合同模板** (`contract_pdf`)
- entity = "contract"
- 签章区域:甲方/乙方
- parties 区块client.name / amount / paid_amount / 期限 / payment_terms
### Task 3.2: 编译 WASM + 升级
同 Task 1.6 流程。
### Task 3.3: 验证
- [ ] 前端打开报价单详情 → 可见"生成 PDF"按钮
- [ ] 点击生成 → 下载 PDF内容包含正确的字段值
- [ ] 发票 PDF → 金额/客户名正确
- [ ] 合同 PDF → 签章区域正确
- [ ] `git add && git commit && git push`
---
## Phase 4: itops Layer 3 — 维保合同 PDF 模板
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
### Task 4.1: 新增 `[[templates]]` 段落1 个模板)
维保合同模板 (`service_contract_pdf`)
- entity = "service_contract"
- SLA 承诺框:响应/解决时间
- grid 布局client.name / amount / 期限 / status
- 服务范围 / 付款条款 / 签章区
### Task 4.2: 编译 WASM + 升级
同 Task 2.6 流程。
### Task 4.3: 验证
- [ ] 前端打开维保合同详情 → 可见"生成 PDF"按钮
- [ ] 点击生成 → 下载 PDFSLA 承诺正确
- [ ] `git add && git commit && git push`
---
## Phase 5: 平台 dashboard widgets 扩展
> **注意:** 此阶段需要修改平台 Rust 代码 + 前端代码,不是纯 plugin.toml 改动。
### Task 5.1: 扩展 manifest.rs — 定义 PluginWidget 类型
**目标文件:** `crates/erp-plugin/src/manifest.rs`
`PluginPageType::Dashboard` 结构体中新增 `widgets` 字段:
```rust
// PluginPageType::Dashboard 新增字段
Dashboard {
label: String,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
widgets: Option<Vec<PluginWidget>>, // 新增
},
```
定义 `PluginWidget` 枚举及其子类型:
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PluginWidget {
StatCards {
label: String,
cards: Vec<StatCard>,
},
ActionList {
label: String,
#[serde(default)]
max_items: Option<u32>,
queries: Vec<ActionQuery>,
},
Funnel {
label: String,
entity: String,
lane_field: String,
#[serde(default)]
value_field: Option<String>,
lane_order: Vec<String>,
},
CardList {
label: String,
entity: String,
#[serde(default)]
filter: Option<String>,
#[serde(default)]
max_items: Option<u32>,
title_field: String,
#[serde(default)]
subtitle_field: Option<String>,
#[serde(default)]
tags: Vec<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatCard {
pub entity: String,
#[serde(default)]
pub aggregate: Option<String>, // count, sum
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub filter: Option<String>,
pub label: String,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionQuery {
pub entity: String,
#[serde(default)]
pub filter: Option<String>,
#[serde(default)]
pub sort: Option<String>,
pub label_field: String,
#[serde(default)]
pub subtitle_field: Option<String>,
pub action: String,
#[serde(default)]
pub icon: Option<String>,
}
```
### Task 5.2: 扩展插件 API — 返回 widgets 数据
**目标文件:** `crates/erp-plugin/src/module.rs`
新增 API 端点,为 dashboard widgets 提供数据:
- `GET /api/v1/plugins/{plugin_id}/dashboard/widgets` — 返回 widgets 定义
- `GET /api/v1/plugins/{plugin_id}/dashboard/data` — 返回 widgets 聚合数据(调用已有 count/aggregate API
### Task 5.3: 前端渲染 dashboard widgets
**目标目录:** `apps/web/src/`
新增组件:
- `PluginDashboard.tsx` — 仪表盘容器,读取 widgets 定义并渲染
- `StatCardsWidget.tsx` — 统计卡片组件4 个指标卡片)
- `ActionListWidget.tsx` — 待办列表组件
- `FunnelWidget.tsx` — 漏斗图组件
- `CardListWidget.tsx` — 卡片列表组件
### Task 5.4: 验证
- [ ] `cargo check` 通过
- [ ] 前端 `pnpm build` 通过
- [ ] manifest.rs 正确解析 widgets TOML
- [ ] API 返回 widgets 定义和聚合数据
- [ ] `git add && git commit && git push`
---
## Phase 6: freelance + itops Layer 2 — 仪表盘 widgets
> **前置:** Phase 5 完成(平台支持 widgets
### Task 6.1: freelance — 替换仪表盘页面为 widgets 版本
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
将现有的空仪表盘(第 949-952 行)替换为包含 4 个 widgets 的完整仪表盘:
1. stat_cards — 财务概览4 张卡片)
2. action_list — 紧急待办4 种查询)
3. funnel — 商机漏斗
4. card_list — 活跃项目
### Task 6.2: itops — 新增仪表盘页面到最前面
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
在现有页面列表最前面插入仪表盘页面2 个 widgets
1. stat_cards — 运维概览4 张卡片)
2. action_list — 紧急待办3 种查询)
### Task 6.3: 两个插件各自编译 WASM + 升级
### Task 6.4: 验证
- [ ] freelance 仪表盘 → 4 个 widget 正确渲染
- [ ] itops 仪表盘 → 2 个 widget 正确渲染
- [ ] 财务卡片数值正确(调用 aggregate API
- [ ] 紧急待办列表有数据时显示条目
- [ ] 商机漏斗按阶段显示金额分布
- [ ] `git add && git commit && git push`
---
## 执行策略
**P1-P4 并行策略:** P1 和 P2 可以同时开始不同文件P3 和 P4 在 P1/P2 完成后立即跟进。每个 Phase 独立编译 WASM、独立验证、独立提交。
**P5-P6 顺序策略:** P5 是平台改动Rust + 前端P6 依赖 P5 的平台能力才能生效。
**预估工作量:**
- P1: 30-40 分钟plugin.toml 编辑 + 编译 + 验证)
- P2: 20-30 分钟(规模小于 P1
- P3: 15-20 分钟3 个模板插入)
- P4: 10-15 分钟1 个模板插入)
- P5: 60-90 分钟manifest 扩展 + API + 前端组件)
- P6: 20-30 分钟plugin.toml widgets 声明)

View File

@@ -0,0 +1,624 @@
# freelance + itops 插件增强设计规格
> 日期: 2026-04-20
> 来源: 多专家头脑风暴UX专家 + 业务顾问 + 运维专家 + 财务专家)
> 状态: Draft
> 前置: `docs/superpowers/specs/2026-04-19-shantou-zhijie-it-services-plugins-design.md`
---
## 1. 背景与动机
当前插件是「数据录入系统」,不是「赚钱工具」。一人 IT 服务公司的核心痛点:
1. **钱从哪里来?** — 商机跟进靠人记,没有自动提醒、没有漏斗分析
2. **项目做到哪了?** — 任务状态和工时手动填,跟合同金额/应收款脱节
3. **钱收回来了吗?** — 报价→合同→开票→收款割裂,没有串联
4. **运维服务会不会忘?** — 巡检计划写了没人催SLA 超时了才知道
5. **税和利润算不清?** — 收支分散在不同表里,月底做账要手动汇总
**问题根因:** 平台已有 trigger_events、settings、templates、cascade_from、visible_when、validation 六大能力,但两个插件完全没有使用。
**改进目标:** 纯插件层增强,三层递进:
- Layer 1: 智能业务引擎 — 让系统主动驱动用户做事
- Layer 2: 仪表盘重构 — 一个页面掌控全局
- Layer 3: 专业输出 — 一键生成报价单/发票/合同 PDF
---
## 2. Layer 1: 智能业务引擎 — freelance 插件
### 2.1 Settings插件配置页
一次性配置公司信息和业务偏好,后续自动生效:
```toml
[settings]
# ── 基本信息 ──
[[settings.fields]]
name = "company_name"
display_name = "公司名称"
field_type = "text"
required = true
group = "基本信息"
[[settings.fields]]
name = "currency_symbol"
display_name = "货币符号"
field_type = "text"
default_value = "¥"
group = "基本信息"
# ── 财务 ──
[[settings.fields]]
name = "default_tax_rate"
display_name = "默认税率(%)"
field_type = "number"
default_value = 6
range = [0.0, 100.0]
group = "财务"
# ── 提醒 ──
[[settings.fields]]
name = "payment_reminder_days"
display_name = "收款提前提醒(天)"
field_type = "number"
default_value = 3
range = [1.0, 30.0]
group = "提醒"
[[settings.fields]]
name = "notify_contract_expiring"
display_name = "合同到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_payment_overdue"
display_name = "逾期收款提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_opportunity_followup"
display_name = "商机跟进提醒"
field_type = "boolean"
default_value = true
group = "提醒"
```
### 2.2 Trigger Events自动事件驱动
关键操作时自动发通知,把"人找事"变"事找人"
```toml
[[trigger_events]]
name = "opportunity_stage_changed"
display_name = "商机阶段变更"
description = "商机阶段发生变化时通知,特别是成交或失败"
entity = "opportunity"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "contract"
on = "update"
[[trigger_events]]
name = "invoice_status_changed"
display_name = "发票状态变更"
description = "发票状态变化时检查逾期收款"
entity = "invoice"
on = "update"
[[trigger_events]]
name = "task_status_changed"
display_name = "任务状态变更"
description = "任务完成或取消时通知"
entity = "task"
on = "update"
[[trigger_events]]
name = "expense_created"
display_name = "新支出记录"
description = "记录新支出时通知"
entity = "expense"
on = "create"
```
### 2.3 Cascade智能联动下拉
选客户后自动过滤其关联数据。以下均为**已有字段追加 cascade 属性**,不是新增字段:
**contract 实体 — 已有 opportunity_id 字段追加:**
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**contract 实体 — 已有 quote_id 字段追加:**
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**invoice 实体 — 已有 project_id 字段追加:**
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**invoice 实体 — 已有 contract_id 字段追加:**
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**time_entry 实体 — 已有 task_id 字段追加:**
```toml
cascade_from = "project_id"
cascade_filter = "project_id"
```
### 2.4 Visible When条件显示
只在有意义时才显示字段。以下为**已有字段追加 visible_when 属性**
**invoice 实体 — 已有 payment_date 字段追加:**
```toml
visible_when = "status == 'paid' || status == 'partial'"
```
**contract 实体 — 已有 paid_amount 字段追加:**
```toml
visible_when = "status != 'drafting'"
```
**task 实体 — 已有 actual_hours 字段追加:**
```toml
visible_when = "status != 'todo'"
```
**quote 实体 — 已有 total_amount 字段追加:**
```toml
visible_when = "status != 'draft'"
```
### 2.5 Validation字段校验
**已有字段追加 validation 属性**,不是新增字段:
**client 实体 — 已有 email 字段追加:**
```toml
validation = { pattern = "^[^@]+@[^@]+\\.[^@]+$", message = "请输入有效的邮箱地址" }
```
**client 实体 — 已有 phone 字段追加:**
```toml
validation = { pattern = "^1[3-9]\\d{9}$", message = "请输入有效的手机号" }
```
---
## 3. Layer 2: 仪表盘重构 — freelance 插件
将占位符仪表盘升级为真正的指挥中心。通过 `widgets` 声明告诉平台该展示什么。
> **平台依赖:** 仪表盘 widgets 需要平台层配合:
> 1. `manifest.rs` 的 `PluginPageType::Dashboard` 需要新增 `widgets: Option<Vec<PluginWidget>>` 字段
> 2. 定义 `PluginWidget` 枚举stat_cards/action_list/funnel/card_list 类型)
> 3. 更新 TOML 解析和验证逻辑
> 4. 前端解析 `widgets` 声明并渲染对应组件
>
> 因此 P5/P6 **不是纯 plugin.toml 改动**,需要平台+前端联合实施。以下 widgets 声明作为设计参考,实施时需先完成平台侧支持。
```toml
[[ui.pages]]
type = "dashboard"
label = "工作台"
icon = "DashboardOutlined"
# ── 财务概览卡片 ──
[[ui.pages.widgets]]
type = "stat_cards"
label = "财务概览"
cards = [
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "type == 'payment' && status != 'overdue'", label = "本月收入", icon = "rise", color = "green" },
{ entity = "expense", aggregate = "sum", field = "amount", label = "本月支出", icon = "fall", color = "red" },
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "status == 'overdue' || status == 'pending'", label = "应收总额", icon = "dollar", color = "orange" },
{ entity = "invoice", aggregate = "count", filter = "status == 'overdue'", label = "逾期笔数", icon = "warning", color = "red" }
]
# ── 紧急待办 ──
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
queries = [
{ entity = "invoice", filter = "status == 'overdue'", label_field = "invoice_number", subtitle_field = "amount", action = "查看", icon = "warning" },
{ entity = "task", filter = "status != 'done' && status != 'cancelled'", sort = "due_date asc", label_field = "title", subtitle_field = "due_date", action = "处理", icon = "clock" },
{ entity = "contract", filter = "status == 'active'", sort = "end_date asc", label_field = "title", subtitle_field = "end_date", action = "续约", icon = "file-text" },
{ entity = "opportunity", filter = "next_follow_up <= today", label_field = "title", subtitle_field = "next_follow_up", action = "跟进", icon = "phone" }
]
# ── 商机漏斗 ──
[[ui.pages.widgets]]
type = "funnel"
label = "商机漏斗"
entity = "opportunity"
lane_field = "stage"
value_field = "estimated_amount"
lane_order = ["visit", "requirement", "quote", "negotiation", "won", "lost"]
# ── 活跃项目卡片 ──
[[ui.pages.widgets]]
type = "card_list"
label = "活跃项目"
entity = "project"
filter = "status == 'in_progress'"
max_items = 4
title_field = "name"
subtitle_field = "contract_amount"
tags = ["business_type", "status"]
```
**依赖:** 数据源来自平台已有的聚合 API`/count``/aggregate`。Filter 表达式使用平台过滤 DSL`==`, `!=`, `||`, `&&`, `<=`)。
---
## 4. Layer 3: 专业输出 — freelance 插件
一键生成专业 PDF替代手动排 Word。
> **模板引擎说明:**
> - 语法基于 Handlebars`{{field}}`, `{{#each relation}}...{{/each}}`
> - 当前实体字段直接可用:`{{amount}}`, `{{status}}`
> - 关系字段解析:`{{client.name}}` 表示通过 `client_id` 引用的 client 实体的 name 字段,渲染器需自动解析
> - `{{#each lines}}` 用于一对多关系(如 quote → quote_line渲染器查询子实体并遍历
> - 平台需要实现 PDF 渲染管道TOML 模板 → Handlebars 渲染(注入数据)→ HTML → wkhtmltopdf/浏览器打印 → PDF
### 4.1 报价单模板
```toml
[[templates]]
name = "quote_pdf"
display_name = "报价单"
entity = "quote"
format = "pdf"
template_html = """
<html>
<head><style>
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
h1 { text-align: center; border-bottom: 2px solid #333; padding-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f5f5f5; }
.total { text-align: right; font-size: 18px; font-weight: bold; }
.footer { margin-top: 40px; color: #666; font-size: 12px; }
</style></head>
<body>
<h1>报价单 {{quote_number}}</h1>
<p>客户:{{client.name}} | 有效期至:{{valid_until}}</p>
<table>
<tr><th>项目</th><th>描述</th><th>数量</th><th>单价</th><th>金额</th></tr>
{{#each lines}}
<tr><td>{{item_name}}</td><td>{{description}}</td><td>{{quantity}}</td><td>{{unit_price}}</td><td>{{amount}}</td></tr>
{{/each}}
</table>
<p class="total">小计:{{subtotal}} | 税率:{{tax_rate}}% | 总计:{{total_amount}}</p>
<div class="footer">备注:{{notes}}</div>
</body>
</html>
"""
```
### 4.2 发票模板
```toml
[[templates]]
name = "invoice_pdf"
display_name = "发票"
entity = "invoice"
format = "pdf"
template_html = """
<html>
<head><style>
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
h1 { text-align: center; color: #1890ff; border-bottom: 2px solid #1890ff; padding-bottom: 10px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
.info-item { padding: 8px; background: #fafafa; }
.amount { font-size: 24px; font-weight: bold; text-align: center; color: #f5222d; margin: 20px 0; }
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 4px; background: #f0f0f0; }
</style></head>
<body>
<h1>发票 {{invoice_number}}</h1>
<div class="info-grid">
<div class="info-item">客户:{{client.name}}</div>
<div class="info-item">类型:{{type}}</div>
<div class="info-item">开票日期:{{issue_date}}</div>
<div class="info-item">到期日:{{due_date}}</div>
</div>
<div class="amount">¥{{amount}}</div>
<p>状态:<span class="status-badge">{{status}}</span></p>
<p>备注:{{notes}}</p>
</body>
</html>
"""
```
### 4.3 合同模板
```toml
[[templates]]
name = "contract_pdf"
display_name = "合同"
entity = "contract"
format = "pdf"
template_html = """
<html>
<head><style>
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
h1 { text-align: center; border-bottom: 3px double #333; padding-bottom: 10px; }
.parties { margin: 20px 0; padding: 15px; background: #fafafa; border-left: 4px solid #1890ff; }
.signature { margin-top: 60px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
.sig-box { border-top: 1px solid #333; padding-top: 10px; text-align: center; }
</style></head>
<body>
<h1>{{title}}</h1>
<p>合同编号:{{contract_number}}</p>
<div class="parties">
<p>甲方:{{client.name}}</p>
<p>合同金额:¥{{amount}} | 已付:¥{{paid_amount}}</p>
<p>期限:{{start_date}} 至 {{end_date}}</p>
<p>付款条款:{{payment_terms}}</p>
</div>
<p>备注:{{notes}}</p>
<div class="signature">
<div class="sig-box">甲方签章</div>
<div class="sig-box">乙方签章</div>
</div>
</body>
</html>
"""
```
---
## 5. itops 插件增强
### 5.1 Settings
```toml
[settings]
[[settings.fields]]
name = "default_sla_response"
display_name = "默认SLA响应时间(小时)"
field_type = "number"
default_value = 8
range = [1.0, 72.0]
group = "SLA"
[[settings.fields]]
name = "default_sla_resolve"
display_name = "默认SLA解决时间(小时)"
field_type = "number"
default_value = 48
range = [1.0, 168.0]
group = "SLA"
[[settings.fields]]
name = "notify_sla_breach"
display_name = "SLA超标提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_check_due"
display_name = "巡检到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
```
### 5.2 Trigger Events
```toml
[[trigger_events]]
name = "ticket_created"
display_name = "新工单"
description = "创建工单时开始SLA计时并通知"
entity = "ticket"
on = "create"
[[trigger_events]]
name = "ticket_status_changed"
display_name = "工单状态变更"
description = "工单状态变化时检查SLA是否达标"
entity = "ticket"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "维保合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "service_contract"
on = "update"
[[trigger_events]]
name = "check_plan_updated"
display_name = "巡检计划更新"
description = "巡检计划更新时检查下次巡检日期"
entity = "check_plan"
on = "update"
```
### 5.3 Cascade
**已有字段追加 cascade 属性**,不是新增字段:
**ticket 实体 — 已有 contract_id 字段追加:**
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**check_record 实体 — 已有 contract_id 字段追加:**
```toml
cascade_from = "plan_id"
cascade_filter = "contract_id"
```
### 5.4 Visible When
**已有字段追加 visible_when 属性**
**ticket 实体 — 已有 resolution 字段追加:**
```toml
visible_when = "status == 'resolved' || status == 'closed'"
```
**ticket 实体 — 已有 responded_at 字段追加:**
```toml
visible_when = "status != 'open'"
```
**ticket 实体 — 已有 resolved_at 字段追加:**
```toml
visible_when = "status == 'resolved' || status == 'closed'"
```
**ticket 实体 — 已有 closed_at 字段追加:**
```toml
visible_when = "status == 'closed'"
```
**check_record 实体 — 已有 issues_found 字段追加:**
```toml
visible_when = "result == 'abnormal'"
```
**check_record 实体 — 已有 actions_taken 字段追加:**
```toml
visible_when = "result == 'abnormal'"
```
### 5.5 Validation
**已有字段追加 validation 属性**
**service_contract 实体 — 已有 contract_number 字段追加:**
```toml
validation = { pattern = "^SC-\\d{4}-\\d{4}$", message = "格式SC-YYYY-NNNN" }
```
### 5.6 Dashboard
> **同 Layer 2 说明:** widgets 需要平台层配合manifest.rs 扩展 + 前端渲染),非纯 plugin.toml 改动。此仪表盘页面**插入到现有页面列表最前面**,现有 4 个页面保持不变。
```toml
[[ui.pages]]
type = "dashboard"
label = "运维概览"
icon = "DashboardOutlined"
[[ui.pages.widgets]]
type = "stat_cards"
label = "运维概览"
cards = [
{ entity = "service_contract", aggregate = "count", filter = "status == 'active'", label = "活跃合同", icon = "file-text", color = "blue" },
{ entity = "ticket", aggregate = "count", filter = "status == 'open' || status == 'in_progress'", label = "待处理工单", icon = "tool", color = "orange" },
{ entity = "ticket", aggregate = "count", filter = "status == 'resolved'", label = "已解决工单", icon = "check-circle", color = "green" },
{ entity = "check_plan", aggregate = "count", filter = "status == 'active'", label = "活跃巡检", icon = "schedule", color = "blue" }
]
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
queries = [
{ entity = "ticket", filter = "status == 'open'", sort = "priority asc", label_field = "title", subtitle_field = "type", action = "处理", icon = "warning" },
{ entity = "service_contract", filter = "status == 'active'", sort = "end_date asc", label_field = "name", subtitle_field = "end_date", action = "续约", icon = "file-text" },
{ entity = "check_plan", filter = "status == 'active'", sort = "next_check_date asc", label_field = "name", subtitle_field = "next_check_date", action = "巡检", icon = "schedule" }
]
```
### 5.7 Template维保合同 PDF
```toml
[[templates]]
name = "service_contract_pdf"
display_name = "维保合同"
entity = "service_contract"
format = "pdf"
template_html = """
<html>
<head><style>
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
h1 { text-align: center; border-bottom: 3px double #1890ff; padding-bottom: 10px; color: #1890ff; }
.sla-box { margin: 20px 0; padding: 15px; background: #e6f7ff; border: 1px solid #91d5ff; border-radius: 4px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
.info-item { padding: 8px; background: #fafafa; }
.signature { margin-top: 60px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
.sig-box { border-top: 1px solid #333; padding-top: 10px; text-align: center; }
</style></head>
<body>
<h1>{{name}}</h1>
<p>合同编号:{{contract_number}}</p>
<div class="info-grid">
<div class="info-item">客户:{{client.name}}</div>
<div class="info-item">合同金额:¥{{amount}}</div>
<div class="info-item">期限:{{start_date}} 至 {{end_date}}</div>
<div class="info-item">状态:{{status}}</div>
</div>
<div class="sla-box">
<strong>SLA 承诺:</strong>响应 {{sla_response_hours}} 小时内 / 解决 {{sla_resolve_hours}} 小时内
</div>
<p>服务范围:{{service_scope}}</p>
<p>付款条款:{{payment_terms}}</p>
<p>备注:{{notes}}</p>
<div class="signature">
<div class="sig-box">甲方签章</div>
<div class="sig-box">乙方签章</div>
</div>
</body>
</html>
"""
```
---
## 6. 改进汇总
| 层次 | 能力 | freelance | itops |
|------|------|-----------|-------|
| Layer 1 | settings | 7 个配置项(公司名/税率/提醒偏好) | 4 个配置项SLA默认值/提醒偏好) |
| Layer 1 | trigger_events | 5 个事件(商机/合同/发票/任务/支出) | 4 个事件(工单/合同/巡检) |
| Layer 1 | cascade | 4 处联动(合同/发票/工时表单) | 2 处联动(工单/巡检记录) |
| Layer 1 | visible_when | 4 个条件字段 | 6 个条件字段 |
| Layer 1 | validation | 2 个校验(邮箱/手机) | 1 个校验(合同编号格式) |
| Layer 2 | dashboard widgets | 财务卡片+紧急待办+商机漏斗+项目卡片 | 运维卡片+紧急待办 |
| Layer 3 | templates | 3 个 PDF报价单/发票/合同) | 1 个 PDF维保合同 |
**总计:** 2 个插件 × 3 层增强,从「数据录入」升级为「赚钱工具」。
---
## 7. 实施优先级
```
P1: freelance Layer 1settings + trigger_events + cascade + visible_when + validation
P2: itops Layer 1settings + trigger_events + cascade + visible_when + validation
P3: freelance Layer 33 个 PDF 模板)
P4: itops Layer 3维保合同 PDF 模板)
P5: freelance Layer 2仪表盘 widgets
P6: itops Layer 2仪表盘 widgets
```
P1-P4 是纯 plugin.toml 改动(给已有字段追加 cascade/visible_when/validation 属性,以及新增 settings/trigger_events/templates 段落可立即实施。P5-P6 的仪表盘 widgets 需要平台层配合:扩展 `manifest.rs``PluginPageType::Dashboard` 支持 `widgets` 字段 + 前端渲染组件。

View File

@@ -0,0 +1,710 @@
# 健康管理系统 — erp-health 模块设计规格
> **文档版本**: 1.0
> **日期**: 2026-04-23
> **状态**: 已确认
> **范围**: V1 — 患者管理 + 健康数据 + 预约排班 + 随访管理 + 咨询管理
---
## 1. 项目背景
### 1.1 产品定位
构建一个面向体检中心/医疗机构的**综合型健康管理平台**,以体检中心为数据源,汇集不同情况的患者,提供全生命周期的健康管理服务。
本系统从 ERP 平台底座分叉独立,作为 **Health Management System (HMS)** 产品演进。ERP 底座提供身份权限、工作流、消息通知、系统配置等基础能力,`erp-health` 作为原生 Rust 模块承载所有医疗业务逻辑。
### 1.2 系统架构
```
📱 患者端(微信小程序) ──┐
├──→ 🔀 API 网关 ──→ 🖥️ ERP 后端HMS
👨‍⚕️ 医护端(小程序/H5 ──┘ │ │
│ ├── erp-auth用户/角色/权限)
│ ├── erp-workflow工作流引擎
│ ├── erp-message消息通知
│ ├── erp-config字典/配置)
│ └── erp-health健康管理★ 新增
└──→ 💾 PostgreSQL + Redis
```
**关键决策:**
- ERP 只负责 **PC 管理后台**功能
- 小程序(患者端/医护端)作为**独立系统**开发
- 数据共享通过 **API 网关**实现
- 健康管理使用**原生 Rust 模块**(非 WASM 插件),获得完整的数据库访问和自定义 API 能力
### 1.3 为什么不用 WASM 插件
| 限制 | 影响 |
|------|------|
| 实体上限 20 个 | 综合健康平台轻松超过 |
| JSONB 存储 | 医疗数据需要强类型、索引、关联 |
| 无自定义 API | 趋势分析、统计报表需要专用端点 |
| 无文件上传 | 化验单、体检报告无法存储 |
| WASM 沙箱限制 | 无法引入加密、AI、外部 API |
原生模块遵循现有模式(如 erp-auth、erp-workflow。**注意:**`ErpModule` trait 没有 `register_routes` 方法。模块通过固有方法 `public_routes()``protected_routes()` 暴露路由,在 `erp-server``main.rs` 中通过 `.nest("/api/v1/health", HealthModule::protected_routes())` 集成。通过 EventBus 通信,未来可平滑拆分为独立微服务。
---
## 2. V1 功能范围
| 模块 | 功能 | 页面数 |
|------|------|--------|
| ① 患者与医护管理 | 患者档案、家庭成员、医护档案、患者标签 | 3 |
| ② 健康数据管理 | 体检记录、日常监测、化验报告、趋势分析 | 3 |
| ③ 预约与排班 | 预约管理、医生排班、日历视图 | 2 |
| ④ 随访管理 | 随访任务、随访记录台账 | 2 |
| ⑤ 咨询管理 | 会话管理、对话记录查看/导出 | 2 |
| ⑥ 医护管理 | 医护人员列表 | 1 |
| **合计** | | **13** |
**V2 预留:** 积分商城、数据统计中心、内容管理增强。
---
## 3. 实体模型
### 3.1 设计原则
- 患者和医护的**账号**走 `erp-auth``users` 表,`erp-health` 只存医疗业务扩展字段
- 通过 `user_id` 外键关联 `users`
- 所有表含 `tenant_id`(多租户隔离)、`id`UUIDv7`created_at``updated_at``created_by``updated_by``deleted_at``version`
- 多对多关系使用中间表
### 3.2 实体定义
#### ① 患者与医护管理
**patient — 患者档案**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | UUIDv7 |
| tenant_id | UUID NOT NULL | 租户 ID |
| user_id | UUID FK → users | 关联 erp-auth 账号 |
| name | VARCHAR(100) | 姓名 |
| gender | VARCHAR(10) | 性别 (male/female/other) |
| birth_date | DATE | 出生日期 |
| blood_type | VARCHAR(10) | 血型 (A/B/AB/O/RH-/RH+) |
| id_number | VARCHAR(20) | 身份证号 |
| allergy_history | TEXT | 过敏史 |
| medical_history_summary | TEXT | 病史摘要 |
| emergency_contact_name | VARCHAR(100) | 紧急联系人姓名 |
| emergency_contact_phone | VARCHAR(20) | 紧急联系人电话 |
| status | VARCHAR(20) | 状态 (active/inactive/deceased) |
| verification_status | VARCHAR(20) | 实名认证 (pending/verified/rejected) |
| source | VARCHAR(100) | 来源(体检中心名称) |
| notes | TEXT | 备注 |
| created_at, updated_at, created_by, updated_by, deleted_at, version | — | 标准字段 |
索引:`(tenant_id, name)`, `(tenant_id, status)`, `(tenant_id, id_number) UNIQUE WHERE deleted_at IS NULL`
**patient_family_member — 家庭成员**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | 患者关联 |
| name | VARCHAR(100) | 姓名 |
| relationship | VARCHAR(50) | 关系(父亲/母亲/配偶/子女等) |
| phone | VARCHAR(20) | 电话 |
| birth_date | DATE | 出生日期 |
| notes | TEXT | 备注 |
| 标准 ERP 字段 | — | |
**doctor_profile — 医护档案**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| user_id | UUID FK → users | 关联 erp-auth 账号 |
| department | VARCHAR(100) | 科室 |
| title | VARCHAR(50) | 职称(主任医师/副主任医师/主治医师等) |
| specialty | VARCHAR(200) | 专长 |
| license_number | VARCHAR(50) | 执业证号 |
| bio | TEXT | 简介 |
| online_status | VARCHAR(20) | 在线状态 (online/offline/busy) |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, patient_id)`
**patient_tag — 患者标签**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| name | VARCHAR(50) | 标签名 |
| color | VARCHAR(20) | 颜色值 |
| description | TEXT | 描述 |
| is_system | BOOLEAN | 系统标签(不可删除) |
| 标准 ERP 字段 | — | |
索引:`UNIQUE (tenant_id, name) WHERE deleted_at IS NULL`
**patient_tag_relation — 患者-标签关联**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| tag_id | UUID FK → patient_tag | |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ | |
| created_by | UUID | |
| updated_by | UUID | |
| deleted_at | TIMESTAMPTZ | 软删除 |
索引:`(tenant_id, patient_id)`, `(tenant_id, tag_id)`
**patient_doctor_relation — 医患关系**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| doctor_id | UUID FK → doctor_profile | |
| relationship_type | VARCHAR(20) | 类型 (primary/consulting) |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ | |
| created_by | UUID | |
| updated_by | UUID | |
| deleted_at | TIMESTAMPTZ | 软删除 |
索引:`(tenant_id, patient_id)`, `(tenant_id, doctor_id)`
#### ② 健康数据管理
**health_record — 体检/就诊记录**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| record_type | VARCHAR(20) | 类型 (checkup/outpatient/inpatient) |
| record_date | DATE | 记录日期 |
| source | VARCHAR(200) | 来源(体检中心/医院名称) |
| overall_assessment | TEXT | 总体评估 |
| report_file_url | VARCHAR(500) | 报告文件 URL |
| notes | TEXT | 备注 |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, patient_id, record_date DESC)`
**vital_signs — 日常监测数据**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| record_date | DATE | 记录日期 |
| systolic_bp_morning | INTEGER | 晨起收缩压 |
| diastolic_bp_morning | INTEGER | 晨起舒张压 |
| systolic_bp_evening | INTEGER | 晚间收缩压 |
| diastolic_bp_evening | INTEGER | 晚间舒张压 |
| heart_rate | INTEGER | 心率 |
| weight | DECIMAL(5,1) | 体重 (kg) |
| blood_sugar | DECIMAL(5,1) | 血糖 (mmol/L) |
| water_intake_ml | INTEGER | 饮水量 (ml) |
| urine_output_ml | INTEGER | 尿量 (ml) |
| notes | TEXT | 备注 |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, patient_id, record_date DESC)`
**lab_report — 化验报告**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| report_date | DATE | 报告日期 |
| report_type | VARCHAR(50) | 报告类型(肾功能/血常规/尿常规等) |
| indicators | JSONB | 指标数据 [{name, value, unit, ref_range, is_abnormal}] |
| image_urls | JSONB | 图片 URLs [url1, url2, ...] |
| doctor_interpretation | TEXT | 医生解读 |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, patient_id, report_date DESC)`, GIN on `indicators`, `(tenant_id, report_type)`
**health_trend — 健康趋势报告**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| period_start | DATE | 周期开始 |
| period_end | DATE | 周期结束 |
| indicator_summary | JSONB | 指标摘要 |
| abnormal_items | JSONB | 异常项 |
| generation_type | VARCHAR(20) | 生成方式 (auto/manual) |
| report_file_url | VARCHAR(500) | 报告文件 URL |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, patient_id, period_start DESC)`
#### ③ 预约排班
**appointment — 预约记录**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| doctor_id | UUID FK → doctor_profile | |
| appointment_type | VARCHAR(20) | 类型 (dialysis/recheck/outpatient) |
| appointment_date | DATE | 预约日期 |
| start_time | TIME | 开始时间 |
| end_time | TIME | 结束时间 |
| status | VARCHAR(20) | 状态 (pending/confirmed/cancelled/completed/no_show) |
| cancel_reason | TEXT | 取消原因 |
| notes | TEXT | 备注 |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, appointment_date, status)`, `(tenant_id, doctor_id, appointment_date)`
**doctor_schedule — 医生排班**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| doctor_id | UUID FK → doctor_profile | |
| schedule_date | DATE | 排班日期 |
| period_type | VARCHAR(20) | 时段 (am/pm/night/full_day) |
| start_time | TIME | 开始时间 |
| end_time | TIME | 结束时间 |
| max_appointments | INTEGER | 最大预约数 |
| current_appointments | INTEGER | 已预约数(默认 0 |
| status | VARCHAR(20) | 状态 (enabled/disabled) |
| 标准 ERP 字段 | — |
索引:`(tenant_id, doctor_id, schedule_date)`, `UNIQUE (tenant_id, doctor_id, schedule_date, period_type) WHERE deleted_at IS NULL`
**预约并发控制:** 创建预约时使用原子 CAS 操作 `UPDATE doctor_schedule SET current_appointments = current_appointments + 1 WHERE id = $1 AND current_appointments < max_appointments RETURNING *`,防止超额预约。
#### ④ 随访管理
**follow_up_task — 随访任务**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| assigned_to | UUID FK → users | 负责医护 |
| follow_up_type | VARCHAR(20) | 类型 (phone/face_to_face/online) |
| planned_date | DATE | 计划日期 |
| status | VARCHAR(20) | 状态 (pending/in_progress/completed/overdue/cancelled) |
| content_template | TEXT | 随访内容模板 |
| related_appointment_id | UUID FK → appointment | 关联预约 |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, assigned_to, status)`, `(tenant_id, planned_date, status)`
**follow_up_record — 随访记录**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| task_id | UUID FK → follow_up_task | |
| executed_by | UUID FK → users | 执行医护 |
| executed_date | DATE | 执行日期 |
| result | VARCHAR(20) | 结果 (followed_up/unreachable/refused/other) |
| patient_condition | TEXT | 患者状况 |
| medical_advice | TEXT | 医嘱建议 |
| next_follow_up_date | DATE | 下次随访日期 |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, task_id)`, `(tenant_id, executed_date)`
#### ⑤ 咨询管理
**consultation_session — 咨询会话**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| doctor_id | UUID FK → doctor_profile | |
| type | VARCHAR(20) | 类型 (customer_service/doctor) |
| status | VARCHAR(20) | 状态 (waiting/active/closed) |
| last_message_at | TIMESTAMPTZ | 最后消息时间 |
| unread_count_patient | INTEGER | 患者未读数 |
| unread_count_doctor | INTEGER | 医生未读数 |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, doctor_id, status)`, `(tenant_id, patient_id, status)`
**consultation_message — 咨询消息**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| session_id | UUID FK → consultation_session | |
| sender_id | UUID | 发送者 ID |
| sender_role | VARCHAR(20) | 角色 (patient/doctor/system) |
| content_type | VARCHAR(20) | 类型 (text/image/voice/file) |
| content | TEXT | 内容 |
| is_read | BOOLEAN | 已读状态(默认 false |
| created_at | TIMESTAMPTZ | 发送时间 |
| updated_at | TIMESTAMPTZ | |
| created_by | UUID | |
| updated_by | UUID | |
| deleted_at | TIMESTAMPTZ | 软删除(内容审核用) |
| version | INT NOT NULL DEFAULT 1 | 乐观锁 |
索引:`(tenant_id, session_id, created_at)`
**数据增长策略:**`created_at` 按月分区PostgreSQL table partitioning超过 1 年的已关闭会话消息归档到冷存储。
**说明:**
- `patient.user_id` 允许 NULL — 患者可先创建档案(如体检中心导入),后续再绑定 erp-auth 账号
- `consultation_message.sender_id` 引用 `users.id` — 统一使用 erp-auth 用户体系标识发送者
---
## 3.3 状态机定义
### appointment.status 转换
```
pending ──→ confirmed ──→ completed
│ │
│ └──→ no_show预约时间过后系统自动或前台手动触发
└──→ cancelled任意时刻可取消需填 cancel_reason
```
### follow_up_task.status 转换
```
pending ──→ in_progress ──→ completed
│ │
└──→ cancelled └──→ overdue系统定时任务planned_date 已过且仍 pending 自动标记)
```
### consultation_session.status 转换
```
waiting ──→ active第一条消息发送时自动触发──→ closed手动关闭或超时自动关闭
```
### patient.status 转换
```
active ──→ inactive手动停用
active ──→ deceased标记死亡不可逆
inactive ──→ active重新激活
```
### patient.verification_status 转换
```
pending ──→ verified实名认证通过
pending ──→ rejected认证被拒
rejected ──→ pending重新提交认证
```
---
## 4. API 设计
所有端点前缀: `/api/v1/health/`
### 4.1 患者管理
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/patients` | 患者列表(分页、搜索、标签筛选) |
| POST | `/patients` | 创建患者 |
| GET | `/patients/:id` | 患者详情 |
| PUT | `/patients/:id` | 更新患者 |
| DELETE | `/patients/:id` | 软删除 |
| POST | `/patients/:id/tags` | 管理标签(批量设置) |
| GET | `/patients/:id/health-summary` | 健康摘要 |
| GET | `/patients/:id/family-members` | 家庭成员列表 |
| POST | `/patients/:id/family-members` | 新增家庭成员 |
| PUT | `/patients/:id/family-members/:fid` | 更新家庭成员 |
| DELETE | `/patients/:id/family-members/:fid` | 删除家庭成员 |
| POST | `/patients/:id/doctors` | 分配主治医生 |
| DELETE | `/patients/:id/doctors/:did` | 移除医患关系 |
### 4.2 健康数据
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/patients/:id/vital-signs` | 日常监测列表 |
| POST | `/patients/:id/vital-signs` | 新增监测数据 |
| GET | `/patients/:id/lab-reports` | 化验报告列表 |
| POST | `/patients/:id/lab-reports` | 新增化验报告 |
| GET | `/patients/:id/health-records` | 体检/就诊记录 |
| POST | `/patients/:id/health-records` | 新增记录 |
| GET | `/patients/:id/trends` | 趋势报告 |
| POST | `/patients/:id/trends/generate` | 生成趋势报告 |
| GET | `/patients/:id/trends/:indicator` | 单指标时序数据 |
| PUT | `/patients/:id/vital-signs/:vid` | 更新监测数据 |
| DELETE | `/patients/:id/vital-signs/:vid` | 删除监测数据 |
| PUT | `/patients/:id/lab-reports/:rid` | 更新化验报告 |
| DELETE | `/patients/:id/lab-reports/:rid` | 删除化验报告 |
| PUT | `/patients/:id/health-records/:rid` | 更新体检记录 |
| DELETE | `/patients/:id/health-records/:rid` | 删除体检记录 |
### 4.3 预约排班
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/appointments` | 预约列表 |
| POST | `/appointments` | 创建预约 |
| PUT | `/appointments/:id/status` | 更新状态 |
| GET | `/doctor-schedules` | 排班列表 |
| POST | `/doctor-schedules` | 创建排班 |
| PUT | `/doctor-schedules/:id` | 更新排班 |
| GET | `/doctor-schedules/calendar` | 日历视图 |
### 4.4 随访管理
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/follow-up-tasks` | 任务列表 |
| POST | `/follow-up-tasks` | 创建任务 |
| PUT | `/follow-up-tasks/:id` | 更新任务 |
| DELETE | `/follow-up-tasks/:id` | 删除任务 |
| POST | `/follow-up-tasks/:id/records` | 填写随访记录 |
| GET | `/follow-up-records` | 随访台账 |
### 4.5 咨询管理
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/consultation-sessions` | 会话列表 |
| GET | `/consultation-sessions/:id/messages` | 消息记录 |
| PUT | `/consultation-sessions/:id/close` | 关闭会话 |
| POST | `/consultation-messages` | 写入消息API 网关用) |
| GET | `/consultation-sessions/export` | 导出 |
### 4.6 医护管理
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/doctors` | 医护列表 |
| POST | `/doctors` | 创建医护档案 |
| GET | `/doctors/:id` | 医护详情 |
| PUT | `/doctors/:id` | 更新医护档案 |
| DELETE | `/doctors/:id` | 软删除医护档案 |
---
## 5. 前端页面设计
文件位置: `apps/web/src/pages/health/`
### 5.1 页面清单
| # | 页面 | 文件名 | 类型 |
|---|------|--------|------|
| 1 | 患者列表 | PatientList.tsx | 表格+搜索+标签筛选+导出 |
| 2 | 患者详情 | PatientDetail.tsx | Tab布局基本信息/健康趋势/化验报告/就诊记录/随访记录 |
| 3 | 标签管理 | PatientTagManage.tsx | CRUD+颜色+批量打标 |
| 4 | 日常监测 | VitalSignsList.tsx | 按患者+日期+ECharts趋势折线图 |
| 5 | 化验报告 | LabReportList.tsx | 列表+图片预览+指标详情+解读 |
| 6 | 体检记录 | HealthRecordList.tsx | 类型筛选+报告文件查看/上传 |
| 7 | 预约管理 | AppointmentList.tsx | 列表/日历切换+状态流转 |
| 8 | 排班管理 | DoctorSchedule.tsx | 周/月日历+排班模板 |
| 9 | 随访任务 | FollowUpTaskList.tsx | 任务CRUD+分配+关联工作流 |
| 10 | 随访台账 | FollowUpRecordList.tsx | 按患者/医护/日期筛选+导出 |
| 11 | 会话管理 | ConsultationList.tsx | 列表+未回复统计 |
| 12 | 对话记录 | ConsultationDetail.tsx | 聊天气泡+图片/语音查看+导出 |
| 13 | 医护列表 | DoctorList.tsx | 列表+科室筛选+在线状态 |
### 5.2 技术要点
- **ECharts 趋势图** — 血压/体重/血糖曲线图,按日期范围展示
- **文件上传/预览** — 化验单图片、体检报告 PDF需新增基础能力
- **日历组件** — Ant Design Calendar 用于排班和预约视图
- **聊天 UI** — 消息气泡展示(只读,非实时聊天)
- **导出** — 随访台账、咨询记录导出为 Excel
---
## 6. 事件集成
### 6.1 发布事件
| 事件类型 | 触发时机 | 载荷 |
|----------|----------|------|
| `patient.created` | 创建患者 | `{patient_id, name, tenant_id}` |
| `patient.updated` | 更新患者信息 | `{patient_id, changed_fields}` |
| `appointment.created` | 创建预约 | `{appointment_id, patient_id, doctor_id, date}` |
| `appointment.confirmed` | 确认预约 | `{appointment_id}` |
| `appointment.cancelled` | 取消预约 | `{appointment_id, cancel_reason}` |
| `appointment.completed` | 完成就诊 | `{appointment_id}` |
| `follow_up.created` | 创建随访任务 | `{task_id, patient_id, assigned_to, planned_date}` |
| `follow_up.completed` | 完成随访 | `{task_id, record_id, result}` |
| `lab_report.uploaded` | 上传化验报告 | `{report_id, patient_id, report_type, abnormal_count}` |
| `consultation.opened` | 开启咨询 | `{session_id, patient_id, doctor_id}` |
| `consultation.closed` | 关闭咨询 | `{session_id}` |
| `patient.deceased` | 患者死亡标记 | `{patient_id}` |
| `patient.verified` | 实名认证通过 | `{patient_id, id_number}` |
| `follow_up.overdue` | 随访任务逾期 | `{task_id, patient_id, planned_date}` |
| `doctor.online_status_changed` | 医护在线状态变更 | `{doctor_id, old_status, new_status}` |
**随访记录自动创建后续任务:**`follow_up_record.next_follow_up_date` 不为空时,服务层自动创建新的 `follow_up_task`planned_date = next_follow_up_dateassigned_to 沿用当前医护)。
### 6.2 订阅事件
| 事件类型 | 处理逻辑 |
|----------|----------|
| `workflow.task.completed` | 工作流任务完成时更新随访任务状态 |
| `message.sent` | 消息发送时联动咨询会话的 last_message_at |
---
## 7. 模块结构
```
crates/erp-health/
├── Cargo.toml
├── src/
│ ├── lib.rs ← ErpModule trait + public_routes() / protected_routes()
│ ├── error.rs ← HealthError → AppError
│ ├── state.rs ← HealthState (共享状态)
│ ├── entity/ ← SeaORM Entity
│ │ ├── mod.rs
│ │ ├── patient.rs
│ │ ├── patient_family_member.rs
│ │ ├── patient_tag.rs
│ │ ├── patient_tag_relation.rs
│ │ ├── patient_doctor_relation.rs
│ │ ├── doctor_profile.rs
│ │ ├── health_record.rs
│ │ ├── vital_signs.rs
│ │ ├── lab_report.rs
│ │ ├── health_trend.rs
│ │ ├── appointment.rs
│ │ ├── doctor_schedule.rs
│ │ ├── follow_up_task.rs
│ │ ├── follow_up_record.rs
│ │ ├── consultation_session.rs
│ │ └── consultation_message.rs
│ ├── service/ ← 业务逻辑
│ │ ├── mod.rs
│ │ ├── patient_service.rs
│ │ ├── health_data_service.rs
│ │ ├── appointment_service.rs
│ │ ├── follow_up_service.rs
│ │ └── consultation_service.rs
│ ├── handler/ ← Axum 路由
│ │ ├── mod.rs
│ │ ├── patient_handler.rs
│ │ ├── health_data_handler.rs
│ │ ├── appointment_handler.rs
│ │ ├── follow_up_handler.rs
│ │ └── consultation_handler.rs
│ ├── dto/ ← 请求/响应结构体
│ │ ├── mod.rs
│ │ ├── patient_dto.rs
│ │ ├── health_data_dto.rs
│ │ ├── appointment_dto.rs
│ │ ├── follow_up_dto.rs
│ │ └── consultation_dto.rs
│ └── event.rs ← 事件定义和处理器
```
---
## 8. 权限定义
### 8.1 权限码
| 权限码 | 名称 | 说明 |
|--------|------|------|
| `health.patient.list` | 查看患者列表 | 查看和搜索患者列表、详情 |
| `health.patient.manage` | 管理患者 | 创建、编辑、删除患者 |
| `health.health-data.list` | 查看健康数据 | 查看体检记录、监测数据、化验报告 |
| `health.health-data.manage` | 管理健康数据 | 录入、编辑、删除健康数据 |
| `health.appointment.list` | 查看预约 | 查看预约列表和排班 |
| `health.appointment.manage` | 管理预约 | 创建、确认、取消预约 |
| `health.follow-up.list` | 查看随访 | 查看随访任务和记录 |
| `health.follow-up.manage` | 管理随访 | 创建、分配、完成随访任务 |
| `health.consultation.list` | 查看咨询 | 查看咨询会话和消息记录 |
| `health.consultation.manage` | 管理咨询 | 关闭会话、导出记录 |
| `health.doctor.list` | 查看医护 | 查看医护列表和详情 |
| `health.doctor.manage` | 管理医护 | 创建、编辑医护档案、排班 |
### 8.2 数据范围
| 实体 | 支持的数据范围级别 | 说明 |
|------|-------------------|------|
| patient | self, department, department_tree, all | 医生只能看自己负责的患者或本科室患者 |
| follow_up_task | self, department, department_tree, all | 医护只能看分配给自己的随访任务 |
| appointment | self, department, department_tree, all | 按科室隔离预约数据 |
### 8.3 角色模板
| 角色 | 权限 |
|------|------|
| health_admin | 全部 health.* 权限 |
| doctor | health.patient.list, health.health-data.*, health.appointment.list, health.follow-up.*, health.consultation.list, health.doctor.list |
| nurse | health.patient.list, health.health-data.*, health.follow-up.*, health.appointment.list |
| receptionist | health.patient.*, health.appointment.*, health.doctor.list |
---
## 9. 能力扩展
V1 需要新增以下基础能力(在 erp-core 或独立模块中):
1. **文件上传服务** — 文件存储(本地/OSS、URL 生成、图片缩略图
2. **趋势分析** — 时序数据聚合、异常检测逻辑
3. **报告批注** — 医生对化验报告的解读/批注能力
4. **导出增强** — 健康数据导出为 Excel/PDF
---
## 10. 实施步骤
### Phase 1: 项目初始化
- 拷贝 ERP 到 hms
- 验证编译和构建
### Phase 2: erp-health 骨架
- 创建 crate 结构
- 实现 ErpModule trait + `public_routes()` / `protected_routes()` 固有方法
- 注册到 workspace
### Phase 3: 数据库迁移
- 16 张表14 业务实体 + 2 关联表)的迁移文件
- 索引创建、唯一约束
### Phase 4: 业务逻辑(按域迭代)
- ① 患者与医护管理
- ② 健康数据管理
- ③ 预约排班
- ④ 随访管理
- ⑤ 咨询管理
### Phase 5: 前端页面
- 13 个自定义 React 页面
- 路由注册和侧边栏菜单
### Phase 6: 集成测试
- API 端点测试
- 多租户隔离验证
- 端到端功能验证

View File

@@ -1,125 +1,102 @@
# architecture (架构决策记录)
---
title: 架构决策记录
updated: 2026-04-23
status: stable
tags: [architecture, decisions, design-principles]
---
## 设计思想
# 架构决策记录
ERP 平台采用 **模块化单体 + 渐进式拆分** 架构。核心原则:模块间零直接依赖,所有跨模块通信通过事件总线和 trait 接口。
> 从 [[index]] 导航。关联: [[erp-core]] [[erp-server]] [[database]] [[wasm-plugin]] [[erp-health]]
## 关键架构决策
## 1. 设计决策
### Q: 为什么用模块化单体而非微服务?
### 模块化单体 + 渐进式拆分
**A:** ERP 系统的模块间数据一致性要求高,分布式事务成本大。单体起步,模块边界清晰,未来需要时可按模块拆分为微服务`ErpModule` trait 天然支持这种渐进式迁移
模块间零直接依赖,跨模块通信通过事件总线和 trait 接口`ErpModule` trait 天然支持未来按模块拆分为微服务
### Q: 为什么用 UUIDv7 而不是自增 ID
### HMS 架构:原生模块 + 插件并存
**A:** UUIDv7 是时间排序的,既有 UUID 的分布式唯一性,又有接近自增 ID 的索引性能。对多租户 SaaS 尤其重要——不同租户的数据不会因为 ID 冲突而互相影响
HMS 继承 ERP 底座的所有基础模块,`erp-health` 作为原生 Rust 模块承载医疗业务。WASM 插件系统保留但非 HMS 主要扩展方式
### Q: 为什么用 broadcast channel 做事件总线?
```
HMS 平台
├── 基础模块(继承 ERP: auth, config, workflow, message, plugin
├── 核心业务模块: erp-health原生 Rust
└── 可选插件: crm, inventory, freelance, itopsWASM
```
**A:** `tokio::sync::broadcast` 提供多消费者发布/订阅,语义匹配模块间事件通知。设计规格要求 outbox 模式(持久化到 domain_events 表),但当前 Phase 1 先用内存 broadcast后续再补持久化。
### 为什么 erp-health 用原生模块?
### Q: 为什么错误类型跨 crate 边界必须用 thiserror
医疗业务需要 16+ 强类型实体、自定义 API趋势分析/统计报表)、文件上传、未来 AI 集成。WASM 插件的 JSONB 动态存储和 20 实体上限无法满足。详见 [[erp-health]]。
**A:** `anyhow` 的错误没有类型信息,无法在 API 层做精确的 HTTP 状态码映射。`thiserror` 定义明确的错误变体,`AppError` 可以精确映射到 400/401/403/404/409/500。crate 内部可以用 `anyhow` 简化,但对外必须转 `AppError`
### 为什么用 UUIDv7
### Q: 为什么 tenant_id 不在 API 路径中?
时间排序 + UUID 唯一性 + 接近自增 ID 的索引性能。多租户 SaaS 下不同租户数据不会因 ID 冲突互相影响。
**A:** 从 JWT token 中提取 tenant_id通过中间件注入 `TenantContext`。这防止了:
- 用户手动修改 URL 访问其他租户数据
- API 路径暴露租户信息
- 开发者忘记检查租户权限
### 为什么 tenant_id 不在 API 路径中?
管理员接口例外,可以通过路径指定 tenant_id
从 JWT 提取,中间件注入 `TenantContext`。防止:手动改 URL 越权 / API 暴露租户信息 / 忘记检查权限。管理员接口例外。
### Q: 为什么前端用 HashRouter 而非 BrowserRouter
### 为什么错误类型跨 crate 用 thiserror
**A:** 部署时可能不在根路径下HashRouter 不需要服务端配置 fallback 路由。对 SPA 来说更稳健
`anyhow` 无类型信息,无法精确映射 HTTP 状态码。`thiserror``AppError` → 400/401/403/404/409/500
## 模块依赖铁律
## 2. 关键文件 + 数据流
### 模块依赖图
```
erp-core (L1)
erp-common (L1)
|
+--------------+--------------+--------------+
| | | |
erp-auth erp-config erp-workflow erp-message (L2)
| | | |
+--------------+--------------+--------------+
+--------------+--------------+--------------+-----------+
| | | | |
erp-auth erp-config erp-workflow erp-message erp-health (L2)
| | | | |
+--------------+--------------+--------------+-----------+
|
erp-server (L3: 唯一组装点)
|
erp-plugin (WASM 插件运行时)
```
**禁止**
- L2 模块之间直接依赖
- L1 模块依赖任何业务模块
- 绕过事件总线直接调用其他模块
**禁止**: L2 间直接依赖 / L1 依赖业务模块 / 绕过事件总线
## 多租户隔离策略
**当前策略:共享数据库 + tenant_id 列过滤**
所有业务表包含 `tenant_id` 列,查询时通过中间件自动注入过滤条件。这是最简单的 SaaS 多租户方案,未来可扩展为:
- Schema 隔离 — 每个租户独立 schema
- 数据库隔离 — 每个租户独立数据库(私有化部署)
`ErpModule::on_tenant_created()``on_tenant_deleted()` 钩子确保模块能在租户创建/删除时初始化/清理数据。
## 技术选型理由
### 技术选型
| 选择 | 理由 |
|------|------|
| Axum 0.8 | Tokio 团队维护,tower 生态无缝集成,类型安全路由 |
| SeaORM 1.1 | 异步、类型安全、Rust 原生 ORM迁移工具完善 |
| PostgreSQL 16 | 企业级关系型数据库JSON 支持,扩展丰富 |
| Redis 7 | 高性能缓存,会话存储,限流 token bucket |
| React 19 + Ant Design 6 | 成熟的组件库,企业后台 UI 标配 |
| Zustand | 极简状态管理,无 boilerplate |
| utoipa | Rust 代码生成 OpenAPI 文档,零额外维护 |
| Wasmtime 43 | WASM 沙箱运行时Component Model 支持Fuel 资源限制 |
| Axum 0.8 | Tokio 团队维护tower 生态,类型安全路由 |
| SeaORM 1.1 | 异步、类型安全、迁移工具完善 |
| PostgreSQL 18 | 企业级JSON 支持,扩展丰富 |
| Redis 7 | 缓存 + 限流 token bucket |
| React 19 + Ant Design 6 | 企业后台 UI 标配 |
| Zustand 5 | 极简状态管理 |
| Wasmtime 43 | WASM 沙箱Component ModelFuel 限制 |
## 插件扩展架构
### 集成契约
### Q: 为什么用 WASM 而不是 Lua / gRPC / dylib
| 方向 | 模块 | 触发时机 |
|------|------|---------|
| 定义 → | [[erp-core]] | 所有模块的 trait 和类型 |
| 组装 ← | [[erp-server]] | 模块注册和启动 |
| 扩展 ← | [[wasm-plugin]] | 插件通过 Host Bridge 桥接 |
**A:**
## 3. 代码逻辑
| 方案 | 安全性 | 隔离性 | 性能 | 复杂度 |
|------|--------|--------|------|--------|
| **WASM** | 高(沙箱) | 进程内隔离 | 接近原生JIT | 中 |
| Lua 脚本 | 中 | 无隔离 | 快 | 低 |
| 进程外 gRPC | 高 | 进程级隔离 | 网络开销 | 高 |
| dylib | 低(直接内存) | 无隔离 | 原生 | 低 |
**不变量**: 模块间只通过 EventBus 和 trait 通信,无直接依赖
**不变量**: 所有数据表必须含 `tenant_id`,查询自动过滤
**不变量**: UUID v7 作为主键
**不变量**: 软删除,不硬删除
**不变量**: 所有 API 使用 `/api/v1/` 前缀
WASM 在安全性和性能之间取得最佳平衡。Wasmtime v43 的 Component Model 提供了类型安全的 Host-Plugin 接口Fuel 机制防止恶意/有缺陷的插件消耗过多资源。
## 4. 活跃问题 + 陷阱
### 插件架构拓扑
⚠️ 当前共享数据库 + tenant_id 过滤,未来可扩展为 Schema 隔离或数据库隔离
⚠️ EventBus 内存 broadcast 需 outbox 持久化保障(已通过后台任务实现)
```
┌─────────────────────────────────────────────────┐
│ erp-server │
│ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ EventBus │ │ PluginRuntime (Wasmtime) │ │
│ │ (broadcast) │ │ ┌──────┐ ┌──────┐ │ │
│ └──────┬───────┘ │ │插件 A│ │插件 B│ │ │
│ │ │ └──┬───┘ └──┬───┘ │ │
│ │ │ │ Host API │ │ │
│ │ │ ┌──┴────────┴──┐ │ │
│ │ │ │ Host Bridge │ │ │
│ │ │ └──┬───────────┘ │ │
│ │ └─────┼────────────────────┘ │
│ │ │ │
│ ┌──────┴───────┐ ┌────┴─────┐ │
│ │ DB (SeaORM) │ │ EventBus │ │
│ └──────────────┘ └──────────┘ │
└─────────────────────────────────────────────────┘
```
## 5. 变更记录
插件通过 Host Bridge 调用系统功能db_insert、event_publish 等Host Bridge 自动注入多租户隔离tenant_id和权限检查。详见 [[wasm-plugin]]。
## 关联模块
- **[[erp-core]]** — 架构契约的定义者
- **[[erp-server]]** — 架构的组装执行者
- **[[database]]** — 多租户隔离的物理实现
- **[[wasm-plugin]]** — 插件扩展架构的实现
| 日期 | 变更 |
|------|------|
| 2026-04-23 | 重构为 5 节结构,删除 erp-common 引用,精简技术选型表 |

View File

@@ -1,73 +1,92 @@
# database (数据库迁移与模式)
---
title: 数据库迁移与模式
updated: 2026-04-23
status: stable
tags: [database, seaorm, migration, multi-tenant]
---
## 设计思想
# 数据库迁移与模式
数据库迁移使用 SeaORM Migration 框架,遵循以下原则:
> 从 [[index]] 导航。关联: [[erp-core]] [[erp-server]] [[infrastructure]]
- **所有表必须包含标准字段** — `id`(UUIDv7), `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
- **软删除** — 不执行硬删除,设置 `deleted_at` 时间戳
## 1. 设计决策
- **SeaORM Migration** — 异步、类型安全、幂等(`if_not_exists`),每个迁移必须实现 `down()` 可回滚
- **所有表必须含标准字段** — `id`(UUIDv7), `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
- **软删除** — 不硬删除,设置 `deleted_at` 时间戳
- **乐观锁** — 更新时检查 `version` 字段
- **多租户隔离** — 所有业务表必须`tenant_id`查询时自动过滤
- **幂等迁移** — 使用 `if_not_exists` 确保可重复执行
- **可回滚** — 每个迁移必须实现 `down()` 方法
- **多租户** — 所有业务表含 `tenant_id`中间件自动过滤
## 代码逻辑
## 2. 关键文件 + 数据流
### 核心文件
| 文件 | 职责 |
|------|------|
| `crates/erp-server/migration/src/lib.rs` | Migrator 注册所有迁移 |
| `crates/erp-server/migration/src/m*.rs` | 41 个迁移文件 |
| `crates/erp-core/src/types.rs` | BaseFields 标准字段定义 |
### 迁移命名规则
### 迁移文件命名规则
```
m{YYYYMMDD}_{6位序号}_{描述}.rs
例: m20260410_000001_create_tenant.rs
```
### 当前表结构
### 当前表概览30 张)
**tenant 表** (唯一已实现的表):
| 列名 | 类型 | 约束 |
|------|------|------|
| id | UUID | PK, NOT NULL |
| name | STRING | NOT NULL |
| code | STRING | NOT NULL, UNIQUE |
| status | STRING | NOT NULL, DEFAULT 'active' |
| settings | JSON | NULLABLE |
| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
| updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
| deleted_at | TIMESTAMPTZ | NULLABLE |
| 模块 | 表 |
|------|-----|
| 基础 | tenant |
| 认证 (auth) | users, user_credentials, user_tokens, roles, permissions, role_permissions, user_roles, organizations, departments, positions, user_departments |
| 配置 (config) | dictionaries, dictionary_items, menus, menu_roles, settings, numbering_rules |
| 工作流 (workflow) | process_definitions, process_instances, tokens, tasks, process_variables |
| 消息 (message) | message_templates, messages, message_subscriptions |
| 审计 | audit_logs, domain_events |
| 插件 (plugin) | plugins, entity_registry, plugin_market, plugin_user_views |
### 已知缺失字段
tenant 表缺少 `BaseFields` 要求的:
- `created_by` — 创建人
- `updated_by` — 最后修改人
- `version` — 乐观锁版本号
### 集成契约
### 迁移执行
```
erp-server 启动 → Migrator::up(&db_conn) → 自动运行所有 pending 迁移
```
| 方向 | 模块 | 触发时机 |
|------|------|---------|
| 消费 ← | [[erp-server]] | 启动时自动运行 `Migrator::up()` |
| 依赖 ← | [[erp-core]] | BaseFields 定义标准字段规范 |
| 提供 → | 所有业务模块 | 表结构供 SeaORM Entity 使用 |
## 关联模块
## 3. 代码逻辑
- **[[erp-core]]** — `BaseFields` 定义了标准字段规范,迁移表结构必须对齐
- **[[erp-server]]** — 启动时自动运行迁移
- **[[erp-auth]]** — Phase 2 将创建 users, roles, permissions 表
- **[[erp-config]]** — Phase 3 将创建 system_configs 表
- **[[erp-workflow]]** — Phase 4 将创建 workflow_definitions, workflow_instances 表
- **[[erp-message]]** — Phase 5 将创建 messages, notification_settings 表
**不变量**: 所有业务表必须含 `tenant_id` 列 — 多租户是核心能力,不可事后补
## 关键文件
**不变量**: 迁移必须幂等 — 使用 `if_not_exists`,可重复执行
| 文件 | 职责 |
**不变量**: 迁移执行由 erp-server 启动自动触发,不手动执行 SQL
### 关键结构变更迁移
| 迁移 | 变更 |
|------|------|
| `crates/erp-server/migration/src/lib.rs` | Migrator 注册所有迁移 |
| `crates/erp-server/migration/src/m20260410_000001_create_tenant.rs` | tenant 表迁移 |
| `crates/erp-core/src/types.rs` | BaseFields 标准字段定义 |
| `docker/docker-compose.yml` | PostgreSQL 16 服务定义 |
| m000027 | 修复唯一索引 + 软删除冲突 |
| m000034 | 种子插件权限 |
| m000035 | pg_trgm 扩展 + entity 列 |
| m000036 | role_permissions 添加 data_scope行级数据权限 |
| m000038 | 修复 CRM 权限码 |
| m000039 | entity_registry 列 |
| m000041 | plugin_user_views |
## 未来迁移计划 (按 Phase)
## 4. 活跃问题 + 陷阱
| Phase | 表 | 说明 |
|-------|-----|------|
| Phase 2 | users, roles, permissions, user_roles, role_permissions | RBAC + ABAC |
| Phase 3 | system_configs, config_histories | 层级配置 |
| Phase 4 | workflow_definitions, workflow_instances, workflow_tasks | BPMN 工作流 |
| Phase 5 | messages, notification_settings, message_templates | 多渠道消息 |
| 持续 | domain_events | 事件 outbox 表 |
### 历史教训
- 唯一索引 + 软删除冲突 — 已删除记录的 unique key 阻止新建m000027 修复)
- tenant 表缺少 `created_by`/`updated_by`/`version` 字段 — 首个迁移早于 BaseFields 规范
⚠️ settings 表的唯一索引曾需修复m000032
⚠️ 新增表时务必对齐 `crates/erp-core/src/types.rs` 中的 BaseFields
## 5. 变更记录
| 日期 | 变更 |
|------|------|
| 2026-04-23 | 重构为 5 节结构,更新表清单至 41 个迁移 |
| 2026-04-19 | CRM 权限码修复迁移 (m000038) |

View File

@@ -1,57 +1,27 @@
---
title: erp-core
updated: 2026-04-23
status: stable
tags: [core, error, event-bus, module-trait, shared-types]
---
# erp-core
## 设计思想
> 从 [[index]] 导航。关联: [[erp-server]] [[database]] [[wasm-plugin]] [[architecture]]
`erp-core` 是整个 ERP 平台的 L1 基础层,所有业务模块的唯一共同依赖。它的职责是定义**跨模块共享的契约**,而非实现业务逻辑。
## 1. 设计决策
核心设计决策:
- **AppError 统一错误体系** — 6 种错误变体映射到 HTTP 状态码,业务 crate 只需 `?` 传播错误,由 Axum `IntoResponse` 自动转换
- **EventBus 进程内广播** — 用 `tokio::sync::broadcast` 实现发布/订阅,模块间零耦合通信
- **ErpModule 插件 trait** — 每个业务模块实现此 trait`ModuleRegistry` 统一注册路由和事件处理器
- **BaseFields 强制多租户** — 所有实体的基础字段模板,确保 `tenant_id` 从第一天就存在
`erp-core` 是 L1 基础层,所有业务模块的唯一共同依赖。定义**跨模块共享的契约**,不含业务逻辑。
## 代码逻辑
核心决策:
- **AppError 统一错误体系** — 6 种变体映射 HTTP 状态码,`?` 传播 + Axum `IntoResponse` 自动转换
- **EventBus 进程内广播** — `tokio::sync::broadcast` 实现零耦合通信
- **ErpModule 插件 trait** — 统一注册路由和事件处理器
- **BaseFields 强制多租户** — 所有实体基础字段模板
### 错误处理链
```
业务 crate (thiserror) → AppError → IntoResponse → HTTP JSON 响应
数据库 (sea_orm::DbErr) → From 转换 → AppError (自动识别 duplicate key → Conflict)
```
## 2. 关键文件 + 数据流
错误响应统一格式:`{ "error": "not_found", "message": "资源不存在", "details": null }`
### 事件总线
```
发布者: EventBus::publish(DomainEvent) → broadcast channel
订阅者: EventBus::subscribe() → Receiver<DomainEvent>
事件字段: id(UUIDv7), event_type("user.created"), tenant_id, payload(JSON), timestamp, correlation_id
```
事件类型命名规则:`{模块}.{动作}``user.created`, `workflow.task.completed`
### 模块注册
```
业务模块实现 ErpModule trait → ModuleRegistry::register() →
build_router(): 折叠所有模块的 register_routes() → Axum Router
register_handlers(): 注册所有模块的事件处理器 → EventBus
```
### 共享类型
- `TenantContext` — 中间件注入的租户上下文tenant_id, user_id, roles, permissions
- `Pagination` / `PaginatedResponse<T>` — 分页查询标准化(每页上限 100
- `ApiResponse<T>` — API 统一信封 `{ success, data, message }`
## 关联模块
- **[[erp-server]]** — 消费所有 erp-core 类型和 trait是唯一组装点
- **[[erp-auth]]** — 实现 `ErpModule` trait发布认证事件
- **[[erp-workflow]]** — 实现 `ErpModule` trait订阅业务事件
- **[[erp-message]]** — 实现 `ErpModule` trait订阅通知事件
- **[[erp-config]]** — 实现 `ErpModule` trait
- **[[database]]** — 迁移表结构必须与 `BaseFields` 对齐
- **[[wasm-plugin]]** — WASM 插件通过 Host Bridge 桥接 EventBus
## 关键文件
### 核心文件
| 文件 | 职责 |
|------|------|
@@ -61,9 +31,63 @@
| `crates/erp-core/src/types.rs` | BaseFields、Pagination、ApiResponse、TenantContext |
| `crates/erp-core/src/lib.rs` | 模块导出入口 |
## 当前状态
### 集成契约
**已实现Phase 1 可用。** 但以下部分尚未被 erp-server 集成:
- `ModuleRegistry` 未在 `main.rs` 中使用
- `EventBus` 未创建实例
- `TenantContext` 未通过中间件注入
| 方向 | 模块 | 接口 | 触发时机 |
|------|------|------|---------|
| 提供 → | erp-auth | ErpModule trait, AppError, EventBus | 模块实现 |
| 提供 → | erp-config | ErpModule trait, AppError | 模块实现 |
| 提供 → | erp-workflow | ErpModule trait, AppError, EventBus | 模块实现 |
| 提供 → | erp-message | ErpModule trait, AppError, EventBus | 模块实现 |
| 提供 → | erp-plugin | ErpModule trait, AppError, EventBus | 模块实现 |
| 消费 ← | [[erp-server]] | ModuleRegistry 组装 | 启动时 |
| 桥接 ← | [[wasm-plugin]] | EventBus → 插件 handle_event | 运行时 |
## 3. 代码逻辑
### 错误处理链
```
业务 crate (thiserror) → AppError → IntoResponse → HTTP JSON
数据库 (sea_orm::DbErr) → From 转换 → AppError (自动识别 duplicate key → Conflict)
```
响应格式:`{ "error": "not_found", "message": "资源不存在", "details": null }`
### 事件总线
```
EventBus::publish(DomainEvent) → broadcast channel → Receiver<DomainEvent>
事件字段: id(UUIDv7), event_type("user.created"), tenant_id, payload(JSON), timestamp
```
命名规则:`{模块}.{动作}``user.created`, `workflow.task.completed`
### 模块注册
```
ErpModule trait → ModuleRegistry::register() →
build_router(): 折叠所有模块路由 → Axum Router
register_handlers(): 注册事件处理器 → EventBus
```
### 共享类型
- `TenantContext` — 租户上下文tenant_id, user_id, roles, permissions, department_ids
- `Pagination` / `PaginatedResponse<T>` — 分页标准化(每页上限 100
- `ApiResponse<T>` — 统一信封 `{ success, data, message }`
**不变量**: erp-core 不依赖任何业务 crate只被依赖
**不变量**: 所有 API 使用 `/api/v1/` 前缀
**不变量**: tenant_id 从 JWT 中间件注入,应用层不可伪造
## 4. 活跃问题 + 陷阱
⚠️ crate 内部可用 `anyhow`,但跨 crate 边界必须转 `AppError`
⚠️ EventBus 当前为内存 broadcastoutbox 持久化通过后台任务实现
## 5. 变更记录
| 日期 | 变更 |
|------|------|
| 2026-04-23 | 重构为 5 节结构,更新为已完全集成状态 |

122
wiki/erp-health.md Normal file
View File

@@ -0,0 +1,122 @@
---
title: erp-health 健康管理模块
updated: 2026-04-23
status: developing
tags: [health, patient, appointment, follow-up, consultation]
---
# erp-health 健康管理模块
> 从 [[index]] 导航。关联: [[erp-core]] [[erp-server]] [[database]] [[frontend]]
>
> 设计规格: `docs/superpowers/specs/2026-04-23-health-management-module-design.md`
## 1. 设计决策
### 为什么用原生模块而非 WASM 插件?
| WASM 插件限制 | 健康模块需求 |
|---------------|-------------|
| 实体上限 20 个 | 16+ 强类型医疗实体 |
| JSONB 动态存储 | 医疗数据需要强类型、索引、关联 |
| 无自定义 API | 趋势分析、统计报表需专用端点 |
| 无文件上传 | 化验单、体检报告需存储 |
| 沙箱限制 | 无法引入加密、AI、外部 API |
### 为什么患者/医护账号走 erp-auth
复用现有用户体系认证、JWT、权限erp-health 只存医疗扩展字段。患者可先建档(体检中心导入),后续再绑定账号。
### 核心架构选择
- **原生 Rust crate** — 与 erp-auth、erp-workflow 同等地位,直接访问数据库
- **固有方法暴露路由** — `public_routes()` / `protected_routes()`,在 erp-server 中 `.nest("/api/v1/health", ...)`
- **EventBus 通信** — 发布 `patient.created``appointment.confirmed` 等,订阅 `workflow.task.completed`
## 2. 关键文件 + 数据流
### 目录结构
```
crates/erp-health/
├── src/
│ ├── lib.rs ← ErpModule trait + routes()
│ ├── error.rs ← HealthError → AppError
│ ├── state.rs ← HealthState
│ ├── entity/ ← 16 个 SeaORM Entity
│ ├── service/ ← 5 个业务 service
│ ├── handler/ ← 5 个路由 handler
│ ├── dto/ ← 请求/响应结构体
│ └── event.rs ← 事件定义和处理器
```
### 实体模型16 张表)
| 域 | 实体 |
|----|------|
| 患者管理 | patient, patient_family_member, patient_tag, patient_tag_relation, patient_doctor_relation |
| 医护管理 | doctor_profile |
| 健康数据 | health_record, vital_signs, lab_report, health_trend |
| 预约排班 | appointment, doctor_schedule |
| 随访管理 | follow_up_task, follow_up_record |
| 咨询管理 | consultation_session, consultation_message |
### 集成契约
| 方向 | 模块 | 接口 | 触发时机 |
|------|------|------|---------|
| 提供 → | [[erp-server]] | `protected_routes()` | 启动时注册 `/api/v1/health/*` |
| 调用 → | [[erp-core]] | EventBus | 发布/订阅领域事件 |
| 关联 → | erp-auth | `users` 表 (user_id FK) | 患者/医护关联账号 |
| 订阅 ← | erp-workflow | `workflow.task.completed` | 随访任务状态更新 |
| 订阅 ← | erp-message | `message.sent` | 咨询会话 last_message_at |
## 3. 代码逻辑
### API 前缀: `/api/v1/health/`
关键端点分组:
- `/patients` — 患者列表/详情/标签管理/健康摘要
- `/patients/:id/vital-signs` — 日常监测数据(血压/心率/体重/血糖)
- `/patients/:id/lab-reports` — 化验报告JSONB 指标数据)
- `/patients/:id/trends` — 健康趋势报告(自动/手动生成)
- `/appointments` — 预约管理(状态机: pending→confirmed→completed
- `/doctor-schedules` — 排班管理(日历视图)
- `/follow-up-tasks` — 随访任务(逾期自动标记)
- `/consultation-sessions` — 咨询会话管理
### 预约并发控制
创建预约时使用原子 CAS`UPDATE doctor_schedule SET current_appointments = current_appointments + 1 WHERE id = $1 AND current_appointments < max_appointments RETURNING *`
### 随访自动链接
`follow_up_record.next_follow_up_date` 不为空时,自动创建新的 `follow_up_task`
### 权限码
`health.patient.list/manage` · `health.health-data.list/manage` · `health.appointment.list/manage` · `health.follow-up.list/manage` · `health.consultation.list/manage` · `health.doctor.list/manage`
**不变量**: 预约创建必须走原子 CAS不能用 read-then-write
**不变量**: `patient.user_id` 允许 NULL先建档后绑定
**不变量**: `consultation_message``created_at` 按月分区,超 1 年归档
## 4. 活跃问题 + 陷阱
### 当前状态: 🔧 开发中
设计规格已确认,尚未开始编码。
### 待解决
| 问题 | 级别 | 说明 |
|------|------|------|
| 文件上传基础能力 | P1 | 化验单/体检报告需要文件存储服务 |
| ECharts 趋势图 | P1 | 前端健康趋势可视化 |
| 导出功能 | P2 | 随访台账/咨询记录导出 Excel |
## 5. 变更记录
| 日期 | 变更 |
|------|------|
| 2026-04-23 | 创建模块 wiki 页,设计规格确认 |

View File

@@ -1,66 +1,110 @@
---
title: erp-server
updated: 2026-04-23
status: stable
tags: [server, axum, assembly, entry-point]
---
# erp-server
## 设计思想
> 从 [[index]] 导航。关联: [[erp-core]] [[infrastructure]] [[database]] [[frontend]]
`erp-server` 是 L3 层——**唯一的组装点**。它不包含业务逻辑,只负责把所有业务模块组装成可运行的服务。
## 1. 设计决策
核心决策:
- **配置优先** — 使用 `config` crate 从 TOML 文件 + 环境变量加载,`ERP__` 前缀覆盖(如 `ERP__DATABASE__URL`
- **启动序列严格有序** — 配置 → 日志 → 数据库 → 迁移 → Redis → 路由 → 监听,每步失败即终止
- **单一入口** — 所有模块通过 `ModuleRegistry` 注册server 本身不直接 import 业务模块的类型
- **唯一组装点** — 不含业务逻辑,只负责把所有模块组装成可运行服务
- **配置优先** — `config` crate 从 TOML + 环境变量加载,`ERP__` 前缀覆盖
- **严格启动序列** — 每步失败即终止,不做部分启动
- **安全检查** — 拒绝默认 JWT 密钥 / 数据库 URL
## 代码逻辑
## 2. 关键文件 + 数据流
### 启动流程 (`main.rs`)
```
1. AppConfig::load() ← config/default.toml + 环境变量
2. init_tracing(level) ← JSON 格式日志
3. db::connect(&db_config) ← SeaORM 连接池 (max=20, min=5)
4. Migrator::up(&db_conn) ← 运行所有待执行迁移
5. redis::Client::open(url) ← Redis 客户端(当前未使用)
6. Router::new() ← 当前仅有 404 fallback
7. bind(host, port).serve() ← 启动 HTTP 服务
```
### 配置结构
```
AppConfig
├── server: ServerConfig { host: "0.0.0.0", port: 3000 }
├── database: DatabaseConfig { url, max_connections: 20, min_connections: 5 }
├── redis: RedisConfig { url: "redis://localhost:6379" }
├── jwt: JwtConfig { secret, access_token_ttl, refresh_token_ttl }
└── log: LogConfig { level: "info" }
```
### 当前状态
- 数据库连接池正常工作
- 迁移自动执行
- **没有注册任何路由** — 仅返回 404
- **没有使用 ModuleRegistry** — 未集成业务模块
- Redis 客户端已创建但未执行任何命令
- 缺少 CORS、压缩、请求追踪中间件
## 关联模块
- **[[erp-core]]** — 提供 AppError、ErpModule trait、ModuleRegistry
- **[[database]]** — 迁移文件通过 `erp-server-migration` crate 引用
- **[[infrastructure]]** — Docker 提供 PostgreSQL 和 Redis 服务
- **[[frontend]]** — Vite 代理 `/api` 请求到 server 的 3000 端口
## 关键文件
### 核心文件
| 文件 | 职责 |
|------|------|
| `crates/erp-server/src/main.rs` | 服务启动入口 |
| `crates/erp-server/src/state.rs` | AppState 定义 |
| `crates/erp-server/src/config.rs` | 5 个配置 struct + 加载逻辑 |
| `crates/erp-server/src/db.rs` | SeaORM 连接池配置 |
| `crates/erp-server/config/default.toml` | 默认配置 |
| `crates/erp-server/Cargo.toml` | 依赖声明 |
| `crates/erp-server/config/default.toml` | 默认配置(密钥为占位符) |
## 待完成 (Phase 1 剩余)
### 启动流程
1. 实例化 `ModuleRegistry` 并注册模块
2. 添加 CORS 中间件tower-http
3. 添加请求追踪中间件
4. 将 Redis 连接注入 AppState
5. 健康检查端点 (`/api/v1/health`)
```
AppConfig::load() → 安全检查 → init_tracing → db::connect → Migrator::up
→ 种子数据(默认租户+管理员) → Redis客户端 → EventBus(容量1024)
→ 注册5个模块 → 初始化插件引擎+恢复插件 → 4个后台任务
→ 构建Router → bind + serve → 优雅关闭(CTRL+C/SIGTERM)
```
### 注册的 5 个模块
AuthModule → ConfigModule → WorkflowModule → MessageModule → PluginModule
### AppState
```
AppState {
db: DatabaseConnection,
config: AppConfig,
event_bus: EventBus,
module_registry: ModuleRegistry,
redis: redis::Client,
default_tenant_id: Uuid,
plugin_engine: PluginEngine,
plugin_entity_cache: moka::Cache (1000容量, 5分钟TTL),
}
```
### 集成契约
| 方向 | 模块 | 接口 | 触发时机 |
|------|------|------|---------|
| 组装 → | erp-auth | `AuthModule` | 启动时注册 |
| 组装 → | erp-config | `ConfigModule` | 启动时注册 |
| 组装 → | erp-workflow | `WorkflowModule` | 启动时注册 |
| 组装 → | erp-message | `MessageModule` | 启动时注册 |
| 组装 → | erp-plugin | `PluginModule` | 启动时注册 |
| 依赖 ← | [[erp-core]] | ErpModule trait, EventBus | 所有模块 |
| 依赖 ← | [[infrastructure]] | PostgreSQL, Redis | 连接 |
### 后台任务
1. 消息监听器 — EventBus → MessageModule
2. 插件通知 — EventBus → PluginModule
3. Outbox relay — domain_events → 外部
4. 超时检查器 — 工作流任务超时处理
## 3. 代码逻辑
### 中间件栈
```
CORS(可配置 origins) → IP限流(公开路由) → 用户限流(受保护路由) → JWT认证
```
### 配置结构
```
AppConfig
├── server: { host: "0.0.0.0", port: 3000 }
├── database: { url, max_connections: 20, min_connections: 5 }
├── redis: { url }
├── jwt: { secret, access_token_ttl: 15min, refresh_token_ttl: 7d }
└── log: { level: "info" }
```
**不变量**: 4 个环境变量在 default.toml 中都是 `__MUST_SET_VIA_ENV__` 占位符,必须通过环境变量设置
**不变量**: 启动顺序不可变更 — 数据库必须先于迁移,迁移必须先于模块注册
## 4. 活跃问题 + 陷阱
⚠️ 后端必须从 `crates/erp-server/` 目录启动(或通过环境变量覆盖所有配置)
⚠️ 种子数据自动创建默认租户和管理员,重复启动幂等
## 5. 变更记录
| 日期 | 变更 |
|------|------|
| 2026-04-23 | 重构为 5 节结构,更新为当前集成状态 |

View File

@@ -1,81 +1,103 @@
# frontend (Web 前端)
---
title: Web 前端
updated: 2026-04-23
status: stable
tags: [frontend, react, antd, vite, spa]
---
## 设计思想
# Web 前端
前端是一个 Vite + React SPA遵循 **UI 层只做展示** 的原则:
> 从 [[index]] 导航。关联: [[erp-server]] [[infrastructure]]
- **组件库优先** — 使用 Ant Design不自造轮子
- **状态集中** — Zustand 管理全局状态(主题、侧边栏、认证)
- **API 层分离** — HTTP 调用封装到 service 层,组件不直接 fetch
- **代理开发** — Vite 开发服务器代理 `/api` 到后端 3000 端口
## 1. 设计决策
版本实际使用情况(与设计规格有差异):
| 技术 | 规格 | 实际 |
|------|------|------|
| React | 18 | 19.2.4 |
| Ant Design | 5 | 6.3.5 |
| React Router | 7 | 7.14.0 |
- **组件库优先** — Ant Design 6不自造轮子
- **状态集中** — Zustand 管理全局状态4 个 store
- **API 层分离** — HTTP 调用封装到 `src/api/`21 个文件),组件不直接 fetch
- **代理开发** — Vite 代理 `/api` 到后端 3000 端口
- **HashRouter** — 不需要服务端 fallback 配置,部署更稳健
- **懒加载** — 除 Login 外所有页面使用 `lazy()` 按需加载
## 代码逻辑
### 版本(以实际 package.json 为准)
### 应用结构
```
main.tsx → App.tsx (ConfigProvider + HashRouter) → MainLayout → 各页面组件
```
React 19.2.4 / Ant Design 6.3.5 / React Router 7.14.0 / Zustand 5.0.12 / Vite 8.0.4 / TypeScript 6.0.2
### MainLayout 布局
经典 SaaS 后台管理布局:
- **左侧 Sidebar** — 可折叠暗色菜单,分组:首页/用户/权限/设置
- **顶部 Header** — 侧边栏切换 + 通知徽标(硬编码5) + 头像("Admin")
- **中间 Content** — React Router Outlet多标签页切换
- **底部 Footer** — 租户名 + 版本号
## 2. 关键文件 + 数据流
### 状态管理 (Zustand)
```typescript
appStore {
isLoggedIn: boolean // 未使用,无登录页
tenantName: string // 默认 "ERP Platform"
theme: 'light' | 'dark' // 切换 Ant Design 主题
sidebarCollapsed: boolean
toggleSidebar(), setTheme(), login(), logout()
}
```
### 开发服务器代理
```
http://localhost:5174/api/* → http://localhost:3000/* (API)
ws://localhost:5174/ws/* → ws://localhost:3000/* (WebSocket)
```
### 当前状态
- 布局壳体完整,暗色/亮色主题切换可用
- 只有一个路由 `/` → 占位 HomePage ("Welcome to ERP Platform")
- 无 API 调用、无认证流程、无真实数据
- 通知计数硬编码为 5用户名硬编码为 "Admin"
- 未实现 i18n代码中有 zh_CN locale 但文案硬编码)
## 关联模块
- **[[erp-server]]** — API 后端,通过 Vite proxy 连接
- **[[infrastructure]]** — Docker 提供 PostgreSQL + Redis
## 关键文件
### 核心文件
| 文件 | 职责 |
|------|------|
| `apps/web/src/main.tsx` | React 入口 |
| `apps/web/src/App.tsx` | 根组件ConfigProvider + 路由 |
| `apps/web/src/layouts/MainLayout.tsx` | 完整后台管理布局 |
| `apps/web/src/stores/app.ts` | Zustand 全局状态 |
| `apps/web/src/index.css` | TailwindCSS 导入 |
| `apps/web/src/App.tsx` | 路由定义(公开 + 受保护) |
| `apps/web/src/layouts/MainLayout.tsx` | SaaS 后台管理布局 |
| `apps/web/src/stores/` | 4 个 Zustand store |
| `apps/web/src/api/` | 21 个 API 服务文件 |
| `apps/web/vite.config.ts` | Vite 配置 + API 代理 |
| `apps/web/package.json` | 依赖声明 |
## 待实现 (按 Phase)
### 路由结构
| Phase | 内容 |
**公开**: `/login`
**受保护MainLayout 包裹)**:
| 路径 | 页面 |
|------|------|
| `/` | 首页 |
| `/users`, `/roles`, `/organizations` | 用户/角色/组织管理 |
| `/workflow` | 工作流 |
| `/messages` | 消息中心 |
| `/settings` | 系统设置 |
| `/plugins/admin`, `/plugins/market` | 插件管理/市场 |
| `/plugins/:pluginId/:entityName` | 插件 CRUD动态生成 |
| `/plugins/:pluginId/tabs|tree|graph|dashboard|kanban/:name` | 插件多视图页面 |
### 集成契约
| 方向 | 模块 | 接口 | 触发时机 |
|------|------|------|---------|
| 调用 → | [[erp-server]] | `/api/v1/*` REST | 所有数据操作 |
| 调用 → | [[erp-server]] | `ws://localhost:3000/ws/*` | WebSocket |
| 消费 ← | 插件系统 | `plugin.toml` schema | 动态生成插件页面 |
## 3. 代码逻辑
### 状态管理4 个 Zustand Store
| Store | 状态 |
|-------|------|
| Phase 2 | 登录页、用户管理页、角色权限页 |
| Phase 3 | 系统配置管理页 |
| Phase 4 | 工作流设计器、审批列表 |
| Phase 5 | 消息中心、通知设置 |
| `app.ts` | theme(light/dark), sidebarCollapsed |
| `auth.ts` | user, isAuthenticated, localStorage 持久化 |
| `message.ts` | unreadCount, recentMessages, 请求去重 |
| `plugin.ts` | plugins 列表, 动态菜单, schema 缓存, 请求去重 |
### 插件页面系统
插件通过 `plugin.toml` schema 声明页面,前端根据 schema 动态生成:
- `PluginCRUDPage` — 标准列表+表单
- `PluginTabsPage` — 标签页切换
- `PluginTreePage` — 树形展示
- `PluginGraphPage` — 关系图谱
- `PluginKanbanPage` — 看板视图
- `PluginDashboardPage` — 仪表盘
**不变量**: 插件菜单由 `plugin.ts` store 从 API 动态获取,不硬编码
**不变量**: API client 在请求前 30s 检查 token 过期,提前刷新避免 401
### 代理配置
```
http://localhost:5174/api/* → http://localhost:3000/* (API)
ws://localhost:5174/ws/* → ws://localhost:3000/* (WebSocket)
```
## 4. 活跃问题 + 陷阱
⚠️ Ant Design 6 废弃 API 警告(`valueStyle`/`Spin tip`/`trailColor`)已在历史版本中修复
⚠️ `antd.setScaleParam` 强制回流 64ms — antd 内部问题,无法直接修复
## 5. 变更记录
| 日期 | 变更 |
|------|------|
| 2026-04-23 | 重构为 5 节结构,更新为当前完整前端状态 |

View File

@@ -1,95 +1,71 @@
# ERP 平台底座 — 知识库
# HMS 健康管理平台 — 知识库
## 项目画像
> **Health Management System (HMS)** — 面向体检中心/医疗机构的综合健康管理平台。从 ERP 底座分叉,继承身份权限/工作流/消息/配置等基础能力,`erp-health` 原生模块承载医疗业务。
**模块化 SaaS ERP 底座**Rust + React 技术栈,提供身份权限/工作流/消息/配置/插件五大基础模块,支持行业业务模块快速插接。
## 关键数字
关键数字:
- 13 个 Rust crate9 个已实现 + 2 个插件原型 + 2 个业务插件1 个前端 SPA
- 37 个数据库迁移
- 6 个业务模块 (auth, config, workflow, message, plugin, server)
- 4 个插件 crate (plugin-prototype, plugin-test-sample, plugin-crm, plugin-inventory)
- Health Check API (`/api/v1/health`)
- OpenAPI JSON (`/api/docs/openapi.json`)
- Phase 1-6 全部完成WASM 插件系统已集成到主服务
- Q2-Q4 成熟度路线图已完成(安全地基/架构强化/测试覆盖/插件生态)
| 指标 | 值 |
|------|-----|
| Rust crate | 14 个7 核心 + erp-health + 6 插件) |
| 数据库表 | 30+ 基础表 + 16 健康业务表(规划中) |
| 核心模块 | 5 基础 (auth/config/workflow/message/plugin) + 1 业务 (health) |
| 健康模块页面 | 13 个(规划中) |
| API 文档 | `http://localhost:3000/api/docs/openapi.json` |
## 模块导航
## 症状导航
### L1 基础层
| 症状 | 先查 | 再查 | 常见根因 |
|------|------|------|----------|
| API 返回 403 | 权限码检查 | [[wasm-plugin]] 权限系统 | 权限码不匹配 / 缺少 .list 权限 |
| API 返回 500 无日志 | [[erp-core]] 错误链 | 后端 tracing 输出 | AppError::Internal 静默 |
| 数据库连接失败 | [[infrastructure]] | PostgreSQL 服务状态 | 服务未启动 / 环境变量未设置 |
| 前端 401 刷新时 | [[frontend]] auth store | API client token 刷新 | token 过期未主动刷新 |
| 迁移执行失败 | [[database]] | PostgreSQL 日志 | 表冲突 / 唯一索引 + 软删除 |
| 端口被占用 | [[infrastructure]] dev.ps1 | 端口 5174-5189 进程 | 残留 Vite 进程 |
| 预约超额 | [[erp-health]] 排班并发 | appointment CAS 操作 | 并发控制未走原子 CAS |
| 跨租户数据泄漏 | [[architecture]] 多租户策略 | [[database]] tenant_id | 查询缺少 tenant_id 过滤 |
## 模块导航
### 基础层(继承自 ERP 底座)
- [[erp-core]] — 错误体系 · 事件总线 · 模块 trait · 共享类型
- [[erp-common]] — ID 生成 · 时间戳 · 编号生成工具
- [[architecture]] — 架构决策 · 设计原则 · 技术选型
### L2 业务层
- erp-auth — 用户/角色/权限/组织/部门/岗位管理 · JWT 认证 · RBAC · 行级数据权限
### 业务层(继承自 ERP 底座)
- erp-auth — 用户/角色/权限/组织/部门/岗位 · JWT · RBAC · 行级数据权限
- erp-config — 字典/菜单/设置/编号规则/主题/语言
- erp-workflow — BPMN 解析 · Token 驱动执行 · 任务分配 · 流程设计器
- erp-message — 消息 CRUD · 模板管理 · 订阅偏好 · 通知面板 · 事件集成
- erp-plugin — 插件管理 · WASM 运行时 · 动态表 · 数据 CRUD · 生命周期管理 · 热更新 · 行级数据权限
- erp-workflow — BPMN 解析 · Token 驱动 · 任务分配
- erp-message — 消息 CRUD · 模板 · 订阅 · 通知面板
- erp-plugin — WASM 运行时 · 动态表 · 热更新HMS 保留但非主要扩展方式)
### L3 组装层
- [[erp-server]] — Axum 服务入口 · AppState · ModuleRegistry 集成 · 配置加载 · 数据库连接 · 优雅关闭
### 核心业务层HMS 专属)
- [[erp-health]] — **患者管理 · 健康数据 · 预约排班 · 随访管理 · 咨询管理**(原生 Rust 模块)
### 插件系统
- [[wasm-plugin]] — Wasmtime 运行时 · WIT 接口契约 · Host API · Fuel 资源限制 · 插件制作完整流程
- erp-plugin-crm — CRM 客户管理插件 (5 实体/9 权限/6 页面)
- erp-plugin-inventory — 进销存管理插件 (6 实体/12 权限/6 页面)
### 组装层
- [[erp-server]] — Axum 入口 · AppState · 模块注册 · 后台任务 · 优雅关闭
### 基础设施
- [[database]] — SeaORM 迁移 · 多租户表结构 · 软删除模式
- [[infrastructure]] — Windows 开发环境 · PostgreSQL 16 · Redis 7 · 一键启动脚本
- [[frontend]] — React SPA · Ant Design 布局 · Zustand 状态
- [[testing]] — 测试环境指南 · 验证清单 · 常见问题
- [[infrastructure]] — 连接信息 · 环境变量 · 一键启动 (**单一真相源**)
- [[database]] — SeaORM 迁移 · 多租户表结构
- [[frontend]] — React 19 SPA · 健康管理页面
- [[testing]] — 验证清单 · 测试分布 · 性能基准
### 横切关注点
- [[architecture]] — 架构决策记录 · 设计原则 · 技术选型理由
## 核心架构问答
## 核心架构决策
**为什么 erp-health 用原生模块而非 WASM 插件?** 医疗业务需要 16+ 强类型实体、自定义 API趋势分析/统计报表)、文件上传(化验单/体检报告)、未来 AI 集成,超出 WASM 插件能力范围。详见 [[erp-health]]。
**模块间如何通信?** 通过 [[erp-core]] EventBus 发布/订阅 DomainEvent,不直接依赖
**模块间如何通信?** [[erp-core]] EventBus 发布/订阅 DomainEvent。erp-health 发布 `patient.created``appointment.confirmed` 等事件,订阅 `workflow.task.completed` 等。详见 [[architecture]]
**多租户怎么隔离?** 共享数据库 + tenant_id 列过滤,中间件从 JWT 注入 TenantContext。详见 [[database]] [[architecture]]。
**多租户怎么隔离?** 共享数据库 + `tenant_id` 列过滤,中间件从 JWT 注入。详见 [[database]] [[architecture]]。
**错误怎么传播?** 业务 crate 用 thiserror → AppError → Axum IntoResponse 自动转 HTTP。详见 [[erp-core]] 错误处理链
**患者/医护与 erp-auth 的关系?** 账号走 `users`erp-health 通过 `user_id` 外键关联扩展字段(科室、职称、档案等)。患者可先建档后绑定账号
**状态如何共享?** AppState 包含 DB、Config、EventBus、ModuleRegistry通过 Axum State 提取器注入所有 handler。
## 文档索引
**ModuleRegistry 怎么工作?** 每个 Phase 2+ 的业务模块实现 ErpModule trait在 main.rs 中链式注册。registry 自动构建路由和事件处理器。
**插件系统怎么扩展业务?** 通过 [[wasm-plugin]] 的 WASM 沙箱运行第三方插件,插件通过 WIT 定义的 Host API 与系统交互。详细流程见插件制作指南。
**版本差异怎么办?** package.json 使用 React 19 + Ant Design 6比规格文档更新以实际代码为准。
**行级数据权限怎么控制?** role_permissions 表增加 data_scope 字段all/self/department/department_treeJWT 中间件注入 department_ids插件数据查询自动拼接 scope 条件。
**插件怎么热更新?** 通过 `/api/v1/admin/plugins/{id}/upgrade` 上传新版本 WASM + manifest系统对比 schema 变更执行增量 DDL卸载旧 WASM 加载新 WASM失败时保持旧版本继续运行。
## 开发进度
| Phase | 内容 | 状态 |
|-------|------|------|
| 1 | 基础设施 | 完成 |
| 2 | 身份与权限 | 完成 |
| 3 | 系统配置 | 完成 |
| 4 | 工作流引擎 | 完成 |
| 5 | 消息中心 | 完成 |
| 6 | 整合与打磨 | 完成 |
| - | WASM 插件原型 | V1-V6 验证通过 |
| - | 插件系统集成 | 已集成到主服务 |
| - | CRM 插件 | 完成 |
| - | Q2 安全地基 + CI/CD | 完成 |
| - | Q3 架构强化 + 前端体验 | 完成 |
| - | Q4 测试覆盖 + 插件生态 | 完成 |
## 关键文档索引
| 文档 | 位置 |
| 类型 | 位置 |
|------|------|
| 设计规格 | `docs/superpowers/specs/2026-04-10-erp-platform-base-design.md` |
| 实施计划 | `docs/superpowers/plans/2026-04-10-erp-platform-base-plan.md` |
| WASM 插件设计 | `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` |
| WASM 插件计划 | `docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md` |
| CRM 插件设计 | `docs/superpowers/specs/2026-04-16-crm-plugin-design.md` |
| CRM 插件计划 | `docs/superpowers/plans/2026-04-16-crm-plugin-plan.md` |
| 健康模块设计规格 | `docs/superpowers/specs/2026-04-23-health-management-module-design.md` |
| 设计规格 | `docs/superpowers/specs/` |
| 实施计划 | `docs/superpowers/plans/` |
| 协作规则 | `CLAUDE.md` |
| 设计评审 | `plans/squishy-pondering-aho-agent-a23c7497aadc6da41.md` |
| 插件制作指南 | `.claude/skills/plugin-development/SKILL.md` |

View File

@@ -1,138 +1,104 @@
# infrastructure (开发环境)
---
title: 开发环境
updated: 2026-04-23
status: stable
tags: [infrastructure, dev-environment, windows, postgresql]
---
## 设计思想
# 开发环境
开发环境在 **Windows** 宿主机直接运行所有服务:
- PostgreSQL 通过 Windows 原生安装运行
- Redis 7+ 通过 Windows 原生安装运行(可选,缺省时限流降级为 fail-open
- 后端 Rust 服务通过 `cargo run` 快速重启
- 前端 Vite 热更新直接在宿主机
- PowerShell 脚本 (`dev.ps1`) 提供一键启动/停止
> 从 [[index]] 导航。关联: [[erp-server]] [[database]] [[frontend]] [[testing]]
>
> **本页是连接信息、启动命令、登录凭据的单一真相源。** 其他页面引用此处。
> Docker Compose 配置保留在 `docker/` 目录下,可供需要容器化环境的场景使用,但日常开发不依赖 Docker。
## 1. 设计决策
## 本机环境实际配置
- **Windows 原生运行** — PostgreSQL/Redis/Rust/Node 直接在宿主机,不用 Docker
- **一键启动** — `dev.ps1` 管理前后端生命周期
- **环境变量优先** — 敏感配置通过 `ERP__` 前缀环境变量覆盖 TOML
> **重要:以下为当前开发机的实际配置,以本文件为准。**
## 2. 关键文件 + 连接信息
| 组件 | 安装路径 | 配置 |
|------|---------|------|
| PostgreSQL 18 | `D:\postgreSQL\` | 服务名 `postgresql-x64-18`, 端口 5432 |
| Redis | 云端实例 | `redis://:redis_KBCYJk@129.204.154.246:6379`, 限流中间件已正常工作 |
| Rust | stable (cargo in PATH) | workspace 根目录编译 |
| Node.js + pnpm | in PATH | apps/web/ |
### 核心文件
### 数据库连接
| 文件 | 职责 |
|------|------|
| `dev.ps1` | 一键启动/停止(自动清理端口 5174-5189 |
| `crates/erp-server/config/default.toml` | 默认配置模板 |
| `docker/docker-compose.yml` | 可选 Docker 配置 |
### 服务连接
| 服务 | 地址 | 用途 |
|------|------|------|
| PostgreSQL 18 | `postgres://postgres:123123@localhost:5432/erp` | 主数据库 |
| Redis 7 | `redis://:redis_KBCYJk@129.204.154.246:6379` (云端) | 缓存 + 限流 |
| 后端 API | `http://localhost:3000/api/v1` | Axum 服务 |
| 前端 SPA | `http://localhost:5174` | Vite 开发服务器 |
### 登录凭据
```
用户: postgres
密码: 123123
数据库: erp
连接串: postgres://postgres:123123@localhost:5432/erp
用户: admin 密码: Admin@2026
```
psql 路径: `D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp`
psql: `D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp`
### 后端启动命令
### 必须设置的环境变量
后端**必须**从 `crates/erp-server/` 目录启动(需要读取 `config/default.toml`),或通过环境变量覆盖:
| 变量 | 开发值 |
|------|--------|
| `ERP__DATABASE__URL` | `postgres://postgres:123123@localhost:5432/erp` |
| `ERP__JWT__SECRET` | `dev-secret-key-change-in-prod` |
| `ERP__AUTH__SUPER_ADMIN_PASSWORD` | `Admin@2026` |
| `ERP__REDIS__URL` | `redis://:redis_KBCYJk@129.204.154.246:6379` |
> 所有四个在 `default.toml` 中为 `__MUST_SET_VIA_ENV__` 占位符
### 集成契约
| 方向 | 模块 | 触发时机 |
|------|------|---------|
| 提供 → | [[erp-server]] | 数据库/Redis 连接 |
| 提供 → | [[frontend]] | Vite 代理目标 |
| 提供 → | [[testing]] | 测试环境配置 |
## 3. 代码逻辑
### 一键启动(推荐)
```powershell
# 方式一:从 crates/erp-server 目录启动(使用 default.toml + 环境变量覆盖)
.\dev.ps1 # 启动后端 + 前端
.\dev.ps1 -Status # 查看端口状态
.\dev.ps1 -Stop # 停止所有服务
.\dev.ps1 -Restart # 重启所有服务
```
### 手动启动
```powershell
# 后端(必须从 crates/erp-server 目录)
cd crates/erp-server
$env:ERP__DATABASE__URL = "postgres://postgres:123123@localhost:5432/erp"
$env:ERP__JWT__SECRET = "dev-secret-key-change-in-prod"
$env:ERP__AUTH__SUPER_ADMIN_PASSWORD = "Admin@2026"
cargo run -p erp-server
# 方式二:一键启动脚本(推荐)
.\dev.ps1
# 前端
cd apps/web && pnpm install && pnpm dev
```
### 登录凭据
**不变量**: 后端必须从 `crates/erp-server/` 目录启动或通过环境变量覆盖所有配置
**不变量**: Vite 固定端口 5174`--strictPort`),前端代理 `/api` → 后端 3000
```
用户名: admin
密码: Admin@2026
```
## 4. 活跃问题 + 陷阱
## 服务端口
⚠️ Redis 不可达时限流自动降级为 fail-open放行所有请求
⚠️ Docker Compose 配置保留在 `docker/` 下但日常开发不依赖
⚠️ 首次 `cargo run` 编译整个 workspace 较慢(含 wasmtime后续增量快
| 服务 | 端口 | 用途 |
|------|------|------|
| PostgreSQL 18 | 5432 | 主数据库 |
| Redis 7+ | 6379 (云端) | 缓存 + 限流 |
| erp-server (Axum) | 3000 | 后端 API |
| Vite dev server | 5174 | 前端 SPA固定端口--strictPort |
## 5. 变更记录
### 连接信息(配置文件版本)
```
PostgreSQL: postgres://postgres:123123@localhost:5432/erp
Redis: redis://:redis_KBCYJk@129.204.154.246:6379 (云端实例)
```
## 一键启动
```powershell
.\dev.ps1 # 启动后端 + 前端(自动清理旧进程 5174-5189
.\dev.ps1 -Status # 查看端口状态
.\dev.ps1 -Stop # 停止所有服务
.\dev.ps1 -Restart # 重启所有服务
```
> `dev.ps1` 会在启动前清理端口 5174-5189 范围内所有残留进程,并使用 `--strictPort` 确保 Vite 固定在 5174 端口。
### 环境变量
必须通过环境变量设置的值(`default.toml` 中为占位符):
| 变量 | 说明 | 开发值 |
|------|------|--------|
| `ERP__DATABASE__URL` | 数据库连接串 | `postgres://postgres:123123@localhost:5432/erp` |
| `ERP__JWT__SECRET` | JWT 签名密钥 | 自定义字符串 |
| `ERP__AUTH__SUPER_ADMIN_PASSWORD` | admin 初始密码 | `Admin@2026` |
| `ERP__REDIS__URL` | Redis 连接串 | `redis://:redis_KBCYJk@129.204.154.246:6379` |
> 所有四个变量在 `default.toml` 中都是 `__MUST_SET_VIA_ENV__` 占位符,**必须**通过环境变量设置,否则服务拒绝启动。
## 关联模块
- **[[erp-server]]** — 连接 PostgreSQL 和 Redis
- **[[database]]** — 迁移在 PostgreSQL 中执行
- **[[frontend]]** — Vite 代理 API 到后端
- **[[testing]]** — 测试环境详细指南
## 关键文件
| 文件 | 职责 |
| 日期 | 变更 |
|------|------|
| `dev.ps1` | 一键启动/停止脚本(自动清理端口 5174-5189 |
| `docker/docker-compose.yml` | 可选的 Docker Compose 配置 |
| `crates/erp-server/config/default.toml` | 默认配置模板(密钥为占位符) |
| `D:\postgreSQL\bin\psql.exe` | PostgreSQL 客户端 |
## 常用命令
```powershell
# 一键启动(推荐)
.\dev.ps1
# 手动启动后端(从 crates/erp-server 目录)
cd crates/erp-server
$env:ERP__DATABASE__URL="postgres://postgres:123123@localhost:5432/erp"
$env:ERP__JWT__SECRET="dev-secret"
$env:ERP__AUTH__SUPER_ADMIN_PASSWORD="Admin@2026"
cargo run -p erp-server
# 手动启动前端(固定端口)
cd apps/web && pnpm dev -- --strictPort
# 连接数据库
D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp
# 健康检查
curl http://localhost:3000/api/v1/health
# 登录测试
curl -s http://localhost:3000/api/v1/auth/login -H "Content-Type: application/json" -d '{"username":"admin","password":"Admin@2026"}'
```
| 2026-04-23 | 重构为 5 节结构,确立为连接信息的单一真相源 |

View File

@@ -1,119 +1,49 @@
# 测试环境指南
---
title: 测试与验证
updated: 2026-04-23
status: stable
tags: [testing, verification]
---
> 本项目在 **Windows** 环境下开发,使用 PowerShell 脚本一键启动。不使用 Docker数据库直接通过原生安装运行。
# 测试与验证
## 环境要求
> 从 [[index]] 导航。关联: [[infrastructure]] [[database]] [[frontend]] [[erp-server]]
| 工具 | 最低版本 | 用途 |
|------|---------|------|
| Rust | stable (1.93+) | 后端编译 |
| Node.js | 20+ | 前端工具链 |
| pnpm | 9+ | 前端包管理 |
| PostgreSQL | 16+ (当前 18) | 主数据库 |
| Redis | 7+ (云端实例) | 缓存 + 限流 |
## 1. 设计决策
## 服务连接信息(实际配置)
- **真实数据库优先** — 集成测试用真实 PostgreSQL不用内存模拟
- **分层验证** — 编译检查 → 单元测试 → 功能验证 → 生产构建
- **环境配置统一由 [[infrastructure]] 管理** — 连接信息、启动命令、登录凭据见该页
| 服务 | 地址 | 用途 |
|------|------|------|
| PostgreSQL | `postgres://postgres:123123@localhost:5432/erp` | 主数据库 |
| Redis | `redis://:redis_KBCYJk@129.204.154.246:6379` (云端) | 缓存 + 限流 |
| 后端 API | `http://localhost:3000/api/v1` | Axum 服务 |
| 前端 SPA | `http://localhost:5174` | Vite 开发服务器 |
## 2. 关键文件 + 验证清单
### 登录信息
### 测试分布
- 用户名: `admin`
- 密码: `Admin@2026`
| Crate | 测试数 | 覆盖 |
|-------|--------|------|
| erp-auth | 8 | 密码哈希、TTL 解析 |
| erp-core | 6 | RBAC 权限检查 |
| erp-workflow | 16 | BPMN 解析、表达式求值 |
| erp-plugin-prototype | 6 | WASM 插件集成 |
| **总计** | **36** | |
## 一键启动(推荐)
使用 PowerShell 脚本管理前后端服务:
```powershell
.\dev.ps1 # 启动后端 + 前端(自动清理旧进程)
.\dev.ps1 -Status # 查看端口状态
.\dev.ps1 -Restart # 重启所有服务
.\dev.ps1 -Stop # 停止所有服务
```
脚本会自动:
1. 清理端口 5174-5189 范围内所有残留进程
2. 编译并启动 Rust 后端 (`cargo run -p erp-server`)
3. 安装前端依赖并启动 Vite 开发服务器 (`pnpm dev -- --strictPort`)
## 手动启动
### 1. 确保基础设施运行
```powershell
# 检查 PostgreSQL 服务状态
Get-Service -Name "postgresql*"
# 如需启动
# PostgreSQL 通常自动启动,服务名 postgresql-x64-18
```
### 2. 启动后端(必须从 crates/erp-server 目录)
```powershell
cd crates/erp-server
$env:ERP__DATABASE__URL = "postgres://postgres:123123@localhost:5432/erp"
$env:ERP__JWT__SECRET = "dev-secret-key-change-in-prod"
$env:ERP__AUTH__SUPER_ADMIN_PASSWORD = "Admin@2026"
cargo run -p erp-server
```
首次运行会自动执行数据库迁移。
### 3. 启动前端
```powershell
cd apps/web
pnpm install # 首次需要安装依赖
pnpm dev # 启动开发服务器(端口 5174固定
```
## 验证清单
### 后端验证
### 编译 + 测试
```bash
# 编译检查(无错误
cargo check
# 全量测试(应全部通过)
cargo test --workspace
# Lint 检查(无警告)
cargo clippy -- -D warnings
# 格式检查
cargo fmt --check
cargo check # 编译无错误
cargo test --workspace # 全量测试
cargo clippy -- -D warnings # Lint 无警告
cargo fmt --check # 格式检查
cd apps/web && pnpm build # 前端生产构建
```
### 前端验证
### 功能验证端点
```bash
cd apps/web
# 安装依赖
pnpm install
# TypeScript 编译 + 生产构建
pnpm build
# 类型检查
pnpm tsc -b
```
### 功能验证
| 端点 | 方法 | 说明 |
|------|------|------|
| `http://localhost:3000/api/v1/health` | GET | 健康检查 |
| `http://localhost:3000/api/docs/openapi.json` | GET | OpenAPI 文档 |
| `http://localhost:5174` | GET | 前端页面 |
| 端点 | 说明 |
|------|------|
| `http://localhost:3000/api/v1/health` | 健康检查 |
| `http://localhost:3000/api/docs/openapi.json` | OpenAPI 文档 |
| `http://localhost:5174` | 前端页面 |
### API 快速测试
@@ -122,143 +52,57 @@ pnpm tsc -b
curl -s http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin@2026"}'
# 列出用户(需要 Token
curl -s http://localhost:3000/api/v1/users \
-H "Authorization: Bearer <token>"
# 列出插件
curl -s http://localhost:3000/api/v1/admin/plugins \
-H "Authorization: Bearer <token>"
```
## 数据库管理
### 前端性能基准2026-04-18 Lighthouse
### 连接数据库
Accessibility / SEO / Best Practices 均 100LCP 840msCLS 0.02
## 3. 代码逻辑
### 集成契约
| 方向 | 模块 | 触发时机 |
|------|------|---------|
| 依赖 ← | [[infrastructure]] | 环境准备、连接信息 |
| 验证 → | [[erp-server]] | 健康检查、API 测试 |
| 验证 → | [[frontend]] | 生产构建 |
**不变量**: 功能验证需要后端服务运行中,编译检查必须先于测试通过
### 数据库常用查询
```bash
# 连接数据库
D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp
```
### 常用查询
```sql
-- 列出所有表
\dt
-- 查看迁移记录
SELECT version FROM seaql_migrations ORDER BY version;
-- 查看插件权限
SELECT code, name FROM permissions WHERE deleted_at IS NULL ORDER BY code;
-- 查看 admin 角色的权限
SELECT p.code FROM role_permissions rp
JOIN permissions p ON rp.permission_id = p.id
JOIN roles r ON rp.role_id = r.id
WHERE r.code = 'admin' AND rp.deleted_at IS NULL AND p.deleted_at IS NULL;
SELECT version FROM seaql_migrations ORDER BY version; -- 迁移记录
SELECT code, name FROM permissions WHERE deleted_at IS NULL ORDER BY code; -- 插件权限
```
### 迁移
## 4. 活跃问题 + 陷阱
迁移在 `crates/erp-server/migration/src/` 目录下。后端启动时自动执行。
### 活跃问题
## 测试详情
### 测试分布
| Crate | 测试数 | 说明 |
|-------|--------|------|
| erp-auth | 8 | 密码哈希、TTL 解析 |
| erp-core | 6 | RBAC 权限检查 |
| erp-workflow | 16 | BPMN 解析、表达式求值 |
| erp-plugin-prototype | 6 | WASM 插件集成测试 |
| **总计** | **36** | |
### 运行特定测试
```bash
# 运行单个 crate 的测试
cargo test -p erp-auth
# 运行匹配名称的测试
cargo test -p erp-core -- require_permission
# 运行插件集成测试
cargo test -p erp-plugin-prototype
# 集成测试(需要 Docker/PostgreSQL
cargo test -p erp-server --test integration
```
## 已知问题2026-04-18 审计)
| 问题 | 严重度 | 状态 |
|------|--------|------|
| CRM 插件权限未分配给 admin 角色 → 数据页面 403 | P0 | ✅ 已修复 |
| CRM 插件权限码与实体名不匹配(`tag.manage` vs `customer_tag`)→ 标签/关系/图谱 403 | P0 | ✅ 已修复(迁移 m20260419_000038 |
| CRM 插件 WASM 二进制错误(存储了测试插件而非 CRM 插件) | P0 | ✅ 已修复 |
| 首页统计卡片永久 loading | P0 | ✅ 已修复 |
| `roles/permissions` 路由被 UUID 解析拦截 | P1 | ✅ 已修复 |
| 统计概览 `tagColor` undefined crash`getEntityPalette` 负数索引) | P1 | ✅ 已修复 |
| 销售漏斗/看板 filter 请求 500CRM customer 表缺少 generated columns | P1 | ✅ 已修复(手动 ALTER TABLE 补齐 `_f_level`/`_f_status`/`_f_customer_type`/`_f_industry`/`_f_region` + 索引) |
| `build_scope_sql` 参数索引硬编码 `$100` 导致 SQL 参数错位 | P1 | ✅ 已修复(动态 `values.len()+1` |
| `AppError::Internal` 无日志输出500 错误静默 | P1 | ✅ 已修复(添加 `tracing::error` 日志) |
| antd 6 废弃 API 警告(`valueStyle`/`Spin tip`/`trailColor` | P2 | ✅ 已修复 |
| 问题 | 级别 | 状态 |
|------|------|------|
| display_name 存储 XSS HTML | P1 | 待修复 |
| 页面刷新时 4 个 401 错误(过期 token 未主动刷新) | P2 | ✅ 已修复proactive token refresh |
| 插件列表重复请求(无并发去重) | P2 | ✅ 已修复fetchPlugins promise 去重) |
| TS 编译错误:未使用变量 | P3 | ✅ 已修复 |
| antd vendor chunk 2.9MBgzip 后约 400KB | P3 | 待优化 |
| antd `setScaleParam` 强制回流 64ms | P3 | antd 内部问题,无法直接修复 |
| antd vendor chunk 2.9MB (gzip ~400KB) | P3 | 待优化 |
详见 `docs/audit-2026-04-18.md`
### 历史教训
### 前端审计摘要2026-04-18 Lighthouse + 性能
- CRM 权限码与实体名不一致 → 403详见 [[wasm-plugin]] 权限命名铁律
- `AppError::Internal` 无日志 → 500 静默(已加 `tracing::error`
- `build_scope_sql` 参数索引硬编码 → SQL 参数错位(已动态化)
| 指标 | 得分 |
⚠️ 首次 `cargo run` 需编译整个 workspace含 wasmtime后续增量快
⚠️ Redis 不可达时限流自动降级为 fail-open
## 5. 变更记录
| 日期 | 变更 |
|------|------|
| Accessibility | 100 |
| SEO | 100 |
| Best Practices | 100 |
| LCP | 840ms |
| CLS | 0.02 |
| TTFB | 4ms |
**已实施的优化:**
- API client proactive token refresh请求前 30s 检查过期,提前刷新避免 401
- plugin store 请求去重promise 复用,防止并发重复调用)
- 生产构建中 StrictMode 双重渲染导致的重复请求不会出现
## 常见问题
### Q: 端口被占用 / 多个 Vite 进程残留
```powershell
# 使用 dev.ps1 自动清理
.\dev.ps1 -Stop
# 手动清理 Vite 残留进程(端口 5174-5189
Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -ge 5174 -and $_.LocalPort -le 5189 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }
```
### Q: 数据库连接失败
1. 确认 PostgreSQL 服务正在运行: `Get-Service -Name "postgresql*"`
2. 使用正确连接串: `postgres://postgres:123123@localhost:5432/erp`
3. psql 路径: `D:\postgreSQL\bin\psql.exe`
### Q: 首次启动很慢
首次 `cargo run` 需要编译整个 workspace特别是 wasmtime后续增量编译会很快。
### Q: Redis 未安装
Redis 已配置为云端实例(`129.204.154.246:6379`)。限流中间件使用固定窗口计数器,登录接口限制 60 秒内 5 次请求。如 Redis 不可达,自动降级为 fail-open放行所有请求
## 关联模块
- [[infrastructure]] — 基础设施配置详情
- [[database]] — 数据库迁移和表结构
- [[frontend]] — 前端技术栈和配置
- [[erp-server]] — 后端服务配置
| 2026-04-23 | 重构为 5 节结构,去除与 infrastructure.md 重复 |
| 2026-04-18 | Lighthouse 审计 + 性能优化 |

View File

@@ -1,514 +1,125 @@
# wasm-plugin (WASM 插件系统)
---
title: WASM 插件系统
updated: 2026-04-23
status: stable
tags: [wasm, plugin, wasmtime, wit]
---
## 设计思想
# WASM 插件系统
ERP 平台通过 WASM 沙箱实现**安全、隔离、热插拔**的业务扩展。插件在 Wasmtime 运行时中执行,只能通过 WIT 定义的 Host API 与系统交互,无法直接访问数据库、文件系统或网络。
> 从 [[index]] 导航。关联: [[erp-core]] [[architecture]] [[erp-server]]
核心决策:
- **WASM 沙箱** — 插件代码在隔离环境中运行Host 控制所有资源访问
- **WIT 接口契约** — 通过 `.wit` 文件定义 Host ↔ 插件的双向接口bindgen 自动生成类型化绑定
- **Fuel 资源限制** — 通过燃料机制限制插件 CPU 使用,防止无限循环
- **声明式 Host API** — 插件通过 `db_insert` / `event_publish` 等函数操作数据Host 自动注入 tenant_id、校验权限
## 1. 设计决策
## 原型验证结果 (V1-V6)
### 为什么选 WASM 而非 Lua / gRPC / dylib
| 验证项 | 状态 | 说明 |
|--------|------|------|
| V1: WIT 接口 + bindgen! 编译 | 通过 | `bindgen!({ path, world })` 生成 Host trait + Guest 绑定 |
| V2: Host 调用插件导出函数 | 通过 | `call_init()` / `call_handle_event()` / `call_on_tenant_created()` |
| V3: 插件回调 Host API | 通过 | 插件中 `host_api::db_insert()` 等正确回调到 HostState |
| V4: async 实例化桥接 | 通过 | `instantiate_async` 正常工作(调用方法本身是同步的) |
| V5: Fuel 资源限制 | 通过 | 低 fuel 时正确 trap不会无限循环 |
| V6: 从二进制动态加载 | 通过 | `.component.wasm` 文件加载,测试插件 110KB |
| 方案 | 安全性 | 隔离性 | 性能 | 复杂度 |
|------|--------|--------|------|--------|
| **WASM** | 高(沙箱) | 进程内隔离 | 接近原生 | 中 |
| Lua 脚本 | | 无隔离 | 快 | 低 |
| 进程外 gRPC | | 进程级隔离 | 网络开销 | 高 |
| dylib | 低 | 无隔离 | 原生 | 低 |
## 项目结构
核心权衡WASM 在安全和性能间取得最佳平衡。Wasmtime v43 Component Model 提供类型安全的 Host-Plugin 接口Fuel 防止无限循环。
### 架构拓扑
```
crates/
erp-plugin-prototype/ ← Host 端运行时
wit/
plugin.wit ← WIT 接口定义
src/
lib.rs ← Engine/Store/Linker 创建、HostState + Host trait 实现
main.rs ← 手动测试入口(空)
tests/
test_plugin_integration.rs ← 6 个集成测试
erp-plugin-test-sample/ ← 测试插件
src/
lib.rs ← 实现 Guest trait调用 Host API
┌─────────────────────────────────────────────┐
erp-server │
┌───────────┐ ┌────────────────────────┐ │
│ EventBus │ │ PluginRuntime(Wasmtime) │ │
│(broadcast)│ │ ┌─────┐ ┌─────┐ │ │
│ └─────┬─────┘ │CRM │库存 │ │ │
└──┬──┘ └──┬──┘
│ │ │ Host Bridge(自动注入 │ │
│ │ tenant_id+权限检查) │ │
│ │ └─────┼──────────────────┘ │
┌─────┴──────┐ │
│ DB(SeaORM) │ │
└────────────┘
└─────────────────────────────────────────────┘
```
## WIT 接口定义
### 原型验证 (V1-V6)
文件:`crates/erp-plugin-prototype/wit/plugin.wit`
全部通过WIT+bindgen 编译、Host 调用插件、插件回调 Host API、async 实例化、Fuel 限制、动态加载。
```
package erp:plugin;
## 2. 关键文件 + 数据流
// Host 暴露给插件的 API插件 import
interface host-api {
db-insert: func(entity: string, data: list<u8>) -> result<list<u8>, string>;
db-query: func(entity: string, filter: list<u8>, pagination: list<u8>) -> result<list<u8>, string>;
db-update: func(entity, id: string, data: list<u8>, version: s64) -> result<list<u8>, string>;
db-delete: func(entity: string, id: string) -> result<_, string>;
event-publish: func(event-type: string, payload: list<u8>) -> result<_, string>;
config-get: func(key: string) -> result<list<u8>, string>;
log-write: func(level: string, message: string);
current-user: func() -> result<list<u8>, string>;
check-permission: func(permission: string) -> result<bool, string>;
}
// 插件导出的 APIHost 调用)
interface plugin-api {
init: func() -> result<_, string>;
on-tenant-created: func(tenant-id: string) -> result<_, string>;
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
}
world plugin-world {
import host-api;
export plugin-api;
}
```
## 关键技术要点
### HasSelf<T> — Linker 注册模式
当 Store 数据类型(`HostState`)直接实现 `Host` trait 时,使用 `HasSelf<T>` 作为 `add_to_linker` 的类型参数:
```rust
use wasmtime::component::{HasSelf, Linker};
let mut linker = Linker::new(engine);
PluginWorld::add_to_linker::<_, HasSelf<HostState>>(&mut linker, |state| state)?;
```
`HasSelf<HostState>` 表示 `Data<'a> = &'a mut HostState`bindgen 生成的 `Host for &mut T` blanket impl 确保调用链正确。
### WASM Component vs Core Module
`wit_bindgen::generate!` 生成的是 core WASM 模块(`.wasm`),但 `Component::from_binary()` 需要 WASM Component 格式。转换步骤:
```bash
# 1. 编译为 core wasm
cargo build -p <plugin-crate> --target wasm32-unknown-unknown --release
# 2. 转换为 component
wasm-tools component new target/wasm32-unknown-unknown/release/<name>.wasm \
-o target/<name>.component.wasm
```
### Fuel 资源限制
```rust
let mut store = Store::new(engine, HostState::new());
store.set_fuel(1_000_000)?; // 分配 100 万 fuel
store.limiter(|state| &mut state.limits); // 内存限制
```
Fuel 不足时WASM 执行会 trap`wasm trap: interrupt`Host 可以捕获并处理。
### 调用方法 — 同步,非 async
bindgen 生成的调用方法(`call_init``call_handle_event`)是同步的:
```rust
// 正确
instance.erp_plugin_plugin_api().call_init(&mut store)?;
// 错误(不存在 async 版本的调用方法)
instance.erp_plugin_plugin_api().call_init(&mut store).await?;
```
但实例化可以异步:`PluginWorld::instantiate_async(&mut store, &component, &linker).await?`
## 关键文件
### 核心文件
| 文件 | 职责 |
|------|------|
| `crates/erp-plugin-prototype/wit/plugin.wit` | WIT 接口定义Host API + Plugin API |
| `crates/erp-plugin-prototype/src/lib.rs` | Host 运行时Engine/Store 创建、HostState、Host trait 实现 |
| `crates/erp-plugin-prototype/src/lib.rs` | Host 运行时Engine/Store/Linker/HostState |
| `crates/erp-plugin-prototype/tests/test_plugin_integration.rs` | V1-V6 集成测试 |
| `crates/erp-plugin-test-sample/src/lib.rs` | 测试插件Guest trait 实现 |
## 关联模块
### WIT 接口概要
- **[[architecture]]** — 插件架构是模块化单体的重要扩展机制
- **[[erp-core]]** — EventBus 事件将被桥接到插件的 `handle_event`
- **[[erp-server]]** — 未来集成插件运行时的组装点
Host 暴露给插件(`host-api``db_insert` `db_query` `db_update` `db_delete` `event_publish` `config_get` `log_write` `current_user` `check_permission`
---
插件导出给 Host`plugin-api``init` `on_tenant_created` `handle_event`
# 插件制作完整流程
### 集成契约
以下是从零创建一个新业务模块插件的完整步骤。
| 方向 | 模块 | 接口 | 触发时机 |
|------|------|------|---------|
| 被调用 ← | [[erp-server]] | `PluginEngine` | 服务启动时恢复插件 |
| 调用 → | [[erp-core]] | `EventBus` | 桥接领域事件到插件 |
| 提供 → | 所有插件 | Host API (9 函数) | 插件运行时 |
## 第一步:准备 WIT 接口
## 3. 代码逻辑
WIT 文件定义 Host 和插件之间的契约。现有接口位于 `crates/erp-plugin-prototype/wit/plugin.wit`
如果新插件需要扩展 Host API如新增文件上传、HTTP 代理等),在 `host-api` interface 中添加函数:
```wit
// 在 host-api 中新增
file-upload: func(filename: string, data: list<u8>) -> result<string, string>;
http-proxy: func(url: string, method: string, body: option<list<u8>>) -> result<list<u8>, string>;
```
如果插件需要新的生命周期钩子,在 `plugin-api` interface 中添加:
```wit
// 在 plugin-api 中新增
on-order-approved: func(order-id: string) -> result<_, string>;
```
修改 WIT 后,需要重新编译 Host crate 和所有插件。
## 第二步:创建插件 crate
`crates/` 下创建新的插件 crate
```bash
mkdir -p crates/erp-plugin-<业务名>
```
`Cargo.toml` 模板:
```toml
[package]
name = "erp-plugin-<业务名>"
version = "0.1.0"
edition = "2024"
description = "<业务描述> WASM 插件"
[lib]
crate-type = ["cdylib"] # 必须是 cdylib 才能编译为 WASM
[dependencies]
wit-bindgen = "0.55" # 生成 Guest 端绑定
serde = { workspace = true }
serde_json = { workspace = true }
```
将新 crate 加入 workspace编辑根 `Cargo.toml`
```toml
members = [
# ... 已有成员 ...
"crates/erp-plugin-<业务名>",
]
```
## 第三步:实现插件逻辑
创建 `src/lib.rs`,实现 `Guest` trait
```rust
//! <业务名> WASM 插件
use serde_json::json;
// 生成 Guest 端绑定(路径指向 Host crate 的 WIT 文件)
wit_bindgen::generate!({
path: "../erp-plugin-prototype/wit/plugin.wit",
world: "plugin-world",
});
// 导入 Host APIbindgen 生成)
use crate::erp::plugin::host_api;
// 导入 Guest traitbindgen 生成)
use crate::exports::erp::plugin::plugin_api::Guest;
// 插件结构体(名称任意,但必须是模块级可见的)
struct MyPlugin;
impl Guest for MyPlugin {
/// 初始化 — 注册默认数据、订阅事件等
fn init() -> Result<(), String> {
host_api::log_write("info", "<业务名>插件初始化");
// 示例:创建默认配置
let config = json!({"default_category": "通用"}).to_string();
host_api::db_insert("<业务>_config", config.as_bytes())
.map_err(|e| format!("初始化失败: {}", e))?;
Ok(())
}
/// 租户创建时 — 初始化租户的默认数据
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
host_api::log_write("info", &format!("新租户: {}", tenant_id));
let data = json!({"tenant_id": tenant_id, "name": "默认仓库"}).to_string();
host_api::db_insert("warehouse", data.as_bytes())
.map_err(|e| format!("创建默认仓库失败: {}", e))?;
Ok(())
}
/// 处理订阅的事件
fn handle_event(event_type: String, payload: Vec<u8>) -> Result<(), String> {
host_api::log_write("debug", &format!("收到事件: {}", event_type));
let data: serde_json::Value = serde_json::from_slice(&payload)
.map_err(|e| format!("解析事件失败: {}", e))?;
match event_type.as_str() {
"order.created" => {
// 处理订单创建事件
let order_id = data["id"].as_str().unwrap_or("");
host_api::log_write("info", &format!("新订单: {}", order_id));
}
"workflow.task.completed" => {
// 处理审批完成事件
let order_id = data["order_id"].as_str().unwrap_or("unknown");
let update = json!({"status": "approved"}).to_string();
host_api::db_update("purchase_order", order_id, update.as_bytes(), 1)
.map_err(|e| format!("更新失败: {}", e))?;
// 发布下游事件
let evt = json!({"order_id": order_id}).to_string();
host_api::event_publish("<业务>.order.approved", evt.as_bytes())
.map_err(|e| format!("发布事件失败: {}", e))?;
}
_ => {
host_api::log_write("debug", &format!("忽略事件: {}", event_type));
}
}
Ok(())
}
}
// 导出插件实例(宏会注册 Guest trait 实现)
export!(MyPlugin);
```
### Host API 速查
| 函数 | 签名 | 用途 |
|------|------|------|
| `db_insert` | `(entity, data) → result<record, string>` | 插入记录Host 自动注入 id/tenant_id/timestamp |
| `db_query` | `(entity, filter, pagination) → result<list, string>` | 查询记录,自动过滤 tenant_id + 排除软删除 |
| `db_update` | `(entity, id, data, version) → result<record, string>` | 更新记录,检查乐观锁 version |
| `db_delete` | `(entity, id) → result<_, string>` | 软删除记录 |
| `event_publish` | `(event_type, payload) → result<_, string>` | 发布领域事件 |
| `config_get` | `(key) → result<value, string>` | 读取系统配置 |
| `log_write` | `(level, message) → ()` | 写日志,自动关联 tenant_id + plugin_id |
| `current_user` | `() → result<user_info, string>` | 获取当前用户信息 |
| `check_permission` | `(permission) → result<bool, string>` | 检查当前用户权限 |
### 数据传递约定
所有 Host API 的数据参数使用 `list<u8>`(即 `Vec<u8>`),约定用 JSON 序列化:
```rust
// 构造数据
let data = json!({"sku": "ITEM-001", "quantity": 100}).to_string();
// 插入
let result_bytes = host_api::db_insert("inventory_item", data.as_bytes())
.map_err(|e| e.to_string())?;
// 解析返回
let record: serde_json::Value = serde_json::from_slice(&result_bytes)
.map_err(|e| e.to_string())?;
let new_id = record["id"].as_str().unwrap();
```
## 第四步:编译为 WASM
```bash
# 编译为 core WASM 模块
cargo build -p erp-plugin-<业务名> --target wasm32-unknown-unknown --release
# 转换为 WASM Component必须Host 只接受 Component 格式)
wasm-tools component new \
target/wasm32-unknown-unknown/release/erp_plugin_<业务名>.wasm \
-o target/erp_plugin_<业务名>.component.wasm
```
检查产物大小(目标 < 2MB
```bash
ls -la target/erp_plugin_<业务名>.component.wasm
```
## 第五步:编写集成测试
`crates/erp-plugin-prototype/tests/` 下创建测试文件,或扩展现有测试:
```rust
use anyhow::Result;
use erp_plugin_prototype::{create_engine, load_plugin};
fn wasm_path() -> String {
"../../target/erp_plugin_<业务名>.component.wasm".into()
}
#[tokio::test]
async fn test_<>_init() -> Result<()> {
let wasm_bytes = std::fs::read(wasm_path())?;
let engine = create_engine()?;
let (mut store, instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?;
// 调用 init
instance.erp_plugin_plugin_api().call_init(&mut store)?
.map_err(|e| anyhow::anyhow!(e))?;
// 验证 Host 端效果
let state = store.data();
assert!(state.db_ops.iter().any(|op| op.entity == "<业务>_config"));
Ok(())
}
#[tokio::test]
async fn test_<>_handle_event() -> Result<()> {
let wasm_bytes = std::fs::read(wasm_path())?;
let engine = create_engine()?;
let (mut store, instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?;
// 先初始化
instance.erp_plugin_plugin_api().call_init(&mut store)?
.map_err(|e| anyhow::anyhow!(e))?;
// 模拟事件
let payload = json!({"id": "ORD-001"}).to_string();
instance.erp_plugin_plugin_api()
.call_handle_event(&mut store, "order.created", payload.as_bytes())?
.map_err(|e| anyhow::anyhow!(e))?;
Ok(())
}
```
## 第六步:运行测试
```bash
# 先确保编译了 component
cargo build -p erp-plugin-<业务名> --target wasm32-unknown-unknown --release
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_<业务名>.wasm \
-o target/erp_plugin_<业务名>.component.wasm
# 运行集成测试
cargo test -p erp-plugin-prototype
```
## 流程速查图
### 插件生命周期
```
1. 修改 WIT如需新接口 crates/erp-plugin-prototype/wit/plugin.wit
2. 创建插件 crate crates/erp-plugin-<名>/
- Cargo.toml (cdylib + wit-bindgen)
- src/lib.rs (impl Guest)
3. 编译 core wasm cargo build --target wasm32-unknown-unknown --release
4. 转为 component wasm-tools component new <in.wasm> -o <out.component.wasm>
5. 编写测试 crates/erp-plugin-prototype/tests/
6. 运行测试 cargo test -p erp-plugin-prototype
安装(manifest+WASM) → 注册权限 → 实例化(Wasmtime) → init()
→ 日常: db_query/db_insert 通过 data_handler 自动注入 tenant_id + 权限校验
→ 事件: EventBus → handle_event()
→ 升级: upload 新 WASM → 增量 DDL → 卸载旧实例 → 加载新实例
```
## 常见问题
### 权限系统
### Q: "attempted to parse a wasm module with a component parser"
**A:** 使用了 core WASM 而非 Component。运行 `wasm-tools component new` 转换。
### Q: "cannot infer type of the type parameter D"
**A:** `add_to_linker` 需要显式指定 `HasSelf<T>``add_to_linker::<_, HasSelf<HostState>>(linker, |s| s)`
### Q: "wasm trap: interrupt"(非 fuel 耗尽)
**A:** 检查是否启用了 epoch_interruption 但未定期 bump epoch。原型阶段建议只使用 fuel 限制。
### Q: 插件中如何调试?
**A:** 使用 `host_api::log_write("debug", "message")` 输出日志Host 端 `store.data().logs` 可查看所有日志。
### Q: 如何限制插件内存?
**A:** 通过 `StoreLimitsBuilder` 配置:
```rust
let limits = StoreLimitsBuilder::new()
.memory_size(10 * 1024 * 1024) // 10MB
.build();
```
## 后续规划
- **Phase 7**: 将原型集成到 erp-server替换模拟 Host API 为真实数据库操作
- **动态表**: 支持 `db_insert("dynamic_table", ...)` 自动创建/迁移表
- **前端集成**: PluginCRUDPage 组件根据 WIT 定义自动生成 CRUD 页面
- **插件市场**: 插件元数据、版本管理、签名验证
## 插件权限系统(关键)
### 权限码格式
插件数据操作的权限码由 `data_handler.rs` 中的 `compute_permission_code()` 按以下规则自动生成:
权限码由 `data_handler.rs``compute_permission_code()` 自动生成:
```
{manifest_id}.{url_entity_name}.{action_suffix}
例: erp-crm.customer.list / erp-crm.customer.manage
```
- `manifest_id`plugin.toml 中 `[metadata].id`(如 `erp-crm`
- `url_entity_name`REST API 路径中的实体名(如 `customer_tag`
- `action_suffix``list`(读操作)或 `manage`(写操作)
**权限命名铁律**: `plugin.toml``permissions[].code` 前缀必须与 `schema.entities[].name` 完全一致,每个实体必须声明 `.list` + `.manage`
| 操作 | 权限码示例 |
|------|-----------|
| 列表/详情 | `erp-crm.customer.list` |
| 创建/更新/删除 | `erp-crm.customer.manage` |
**Host API 数据约定**: 所有数据参数用 `list<u8>` + JSON 序列化Host 自动注入 id/tenant_id/timestamp
### 权限码命名铁律P0 级)
**同步调用**: bindgen 生成的 `call_init`/`call_handle_event` 是同步的,只有实例化可以 async
**`plugin.toml``permissions[].code` 的前缀必须与 `schema.entities[].name` 完全一致。**
### 不变量
```
data_handler 生成:{manifest_id}.{url_entity_name}.{action}
↑ 来自 URL 路径中的 entity 参数
manifest 声明: {entity_name}.{action}
↑ 必须与 URL 中的 entity name 匹配
```
⚡ Fuel 默认 100 万,耗尽时 WASM trap`wasm trap: interrupt`
`HasSelf<HostState>` 是 Linker 注册的必要类型参数(`Data<'a> = &'a mut HostState`
⚡ Core WASM 必须通过 `wasm-tools component new` 转为 Component 格式才能被 Host 加载
每个实体必须同时声明 `.list``.manage` 两个权限:
## 4. 活跃问题 + 陷阱
```toml
# ✅ 正确:权限码前缀与实体名一致
[[schema.entities]]
name = "customer_tag"
### 历史教训
[[permissions]]
code = "customer_tag.list" # 匹配!
- CRM 权限码 `tag.manage` vs 实体 `customer_tag` → 三个页面 403迁移 m000038 修复)
- CRM WASM 二进制错误存储了测试插件而非 CRM 插件(重新编译修复)
- 权限未自动分配给 admin 角色 → 403添加 `grant_permissions_to_admin()`
[[permissions]]
code = "customer_tag.manage" # 匹配!
### 注意事项
# ❌ 错误:权限码用了简写,与实体名不一致 → 403
[[permissions]]
code = "tag.manage" # data_handler 生成 erp-crm.customer_tag.manage
# 但 DB 中只有 erp-crm.tag.manage → 403
```
⚠️ 插件 API 路由用 `Path<(Uuid, String)>` 解析 plugin_id必须用数据库 UUID 而非 manifest_id
⚠️ 修改 WIT 后需重编译 Host crate 和所有插件
**历史教训:** CRM 插件首个版本中,`customer_tag` 实体的权限码写成了 `tag.manage``customer_relationship` 实体的权限码写成了 `relationship.list/manage`。结果标签管理、客户关系、关系图谱三个页面全部 403。修复迁移`m20260419_000038_fix_crm_permission_codes.rs`
## 5. 变更记录
### 权限注册流程
1. **插件安装时**`register_plugin_permissions()` 将 manifest 中声明的权限批量 INSERT 到 `permissions` 表(`ON CONFLICT DO NOTHING` 保证幂等)
2. **权限分配**`grant_permissions_to_admin()` 自动将权限分配给 admin 角色
3. **运行时校验**`data_handler.rs``compute_permission_code()` 按 URL entity name 生成权限码,通过 `require_permission()` 检查 JWT 中的权限列表
### 已修复问题
| 问题 | 修复 |
| 日期 | 变更 |
|------|------|
| 权限未自动分配给 admin 角色 → 403 | `grant_permissions_to_admin()` 在 install/enable 时自动调用 |
| 权限码与实体名不匹配 → 403 | 迁移 m20260419_000038 + plugin.toml 修正 |
### 插件 API 路由注意事项
| 2026-04-23 | 重构为 5 节结构,插件制作流程移至 `.claude/skills/plugin-development/` |
| 2026-04-19 | CRM 权限码修复 (m000038) |
| 2026-04-18 | 插件权限系统审计 |
- 后端路由使用 `Path<(Uuid, String)>` 解析 `plugin_id`,必须是 UUID 格式
- 前端使用 `plugin.id`(数据库 UUID而非 `manifest_id`(如 `erp-crm`)构建请求 URL
- 直接用 manifest_id 调用 API 会返回 `UUID parsing failed` 错误
> **插件制作完整流程**: 详见 `.claude/skills/plugin-development/SKILL.md`WIT 接口 → 创建 crate → 编译 WASM → 集成测试)