Compare commits
10 Commits
74378a7575
...
ca50d32f6e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca50d32f6e | ||
|
|
5ac8e18d74 | ||
|
|
89fc482d99 | ||
|
|
85e732cf12 | ||
|
|
8f3d2d58e7 | ||
|
|
40b37cc776 | ||
|
|
301178067c | ||
|
|
7e063a7e88 | ||
|
|
bcc6662add | ||
|
|
f4afc969bd |
45
CLAUDE.md
45
CLAUDE.md
@@ -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
20
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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
273
DESIGN.md
Normal 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
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
|
||||
@@ -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} 数据分布
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />,
|
||||
};
|
||||
|
||||
// ── 延迟类名工具 ──
|
||||
|
||||
@@ -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[] }[];
|
||||
}
|
||||
|
||||
@@ -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)' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)' },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
19
crates/erp-health/Cargo.toml
Normal file
19
crates/erp-health/Cargo.toml
Normal 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
|
||||
95
crates/erp-health/src/dto/appointment_dto.rs
Normal file
95
crates/erp-health/src/dto/appointment_dto.rs
Normal 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>,
|
||||
}
|
||||
46
crates/erp-health/src/dto/consultation_dto.rs
Normal file
46
crates/erp-health/src/dto/consultation_dto.rs
Normal 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>,
|
||||
}
|
||||
123
crates/erp-health/src/dto/health_data_dto.rs
Normal file
123
crates/erp-health/src/dto/health_data_dto.rs
Normal 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)>,
|
||||
}
|
||||
4
crates/erp-health/src/dto/mod.rs
Normal file
4
crates/erp-health/src/dto/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod appointment_dto;
|
||||
pub mod consultation_dto;
|
||||
pub mod health_data_dto;
|
||||
pub mod patient_dto;
|
||||
93
crates/erp-health/src/dto/patient_dto.rs
Normal file
93
crates/erp-health/src/dto/patient_dto.rs
Normal 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>,
|
||||
}
|
||||
55
crates/erp-health/src/entity/appointment.rs
Normal file
55
crates/erp-health/src/entity/appointment.rs
Normal 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 {}
|
||||
43
crates/erp-health/src/entity/consultation_message.rs
Normal file
43
crates/erp-health/src/entity/consultation_message.rs
Normal 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 {}
|
||||
61
crates/erp-health/src/entity/consultation_session.rs
Normal file
61
crates/erp-health/src/entity/consultation_session.rs
Normal 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 {}
|
||||
54
crates/erp-health/src/entity/doctor_profile.rs
Normal file
54
crates/erp-health/src/entity/doctor_profile.rs
Normal 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 {}
|
||||
45
crates/erp-health/src/entity/doctor_schedule.rs
Normal file
45
crates/erp-health/src/entity/doctor_schedule.rs
Normal 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 {}
|
||||
48
crates/erp-health/src/entity/follow_up_record.rs
Normal file
48
crates/erp-health/src/entity/follow_up_record.rs
Normal 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 {}
|
||||
55
crates/erp-health/src/entity/follow_up_task.rs
Normal file
55
crates/erp-health/src/entity/follow_up_task.rs
Normal 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 {}
|
||||
48
crates/erp-health/src/entity/health_record.rs
Normal file
48
crates/erp-health/src/entity/health_record.rs
Normal 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 {}
|
||||
47
crates/erp-health/src/entity/health_trend.rs
Normal file
47
crates/erp-health/src/entity/health_trend.rs
Normal 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 {}
|
||||
46
crates/erp-health/src/entity/lab_report.rs
Normal file
46
crates/erp-health/src/entity/lab_report.rs
Normal 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 {}
|
||||
16
crates/erp-health/src/entity/mod.rs
Normal file
16
crates/erp-health/src/entity/mod.rs
Normal 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;
|
||||
74
crates/erp-health/src/entity/patient.rs
Normal file
74
crates/erp-health/src/entity/patient.rs
Normal 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 {}
|
||||
51
crates/erp-health/src/entity/patient_doctor_relation.rs
Normal file
51
crates/erp-health/src/entity/patient_doctor_relation.rs
Normal 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 {}
|
||||
46
crates/erp-health/src/entity/patient_family_member.rs
Normal file
46
crates/erp-health/src/entity/patient_family_member.rs
Normal 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 {}
|
||||
39
crates/erp-health/src/entity/patient_tag.rs
Normal file
39
crates/erp-health/src/entity/patient_tag.rs
Normal 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 {}
|
||||
50
crates/erp-health/src/entity/patient_tag_relation.rs
Normal file
50
crates/erp-health/src/entity/patient_tag_relation.rs
Normal 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 {}
|
||||
59
crates/erp-health/src/entity/vital_signs.rs
Normal file
59
crates/erp-health/src/entity/vital_signs.rs
Normal 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 {}
|
||||
63
crates/erp-health/src/error.rs
Normal file
63
crates/erp-health/src/error.rs
Normal 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>;
|
||||
7
crates/erp-health/src/event.rs
Normal file
7
crates/erp-health/src/event.rs
Normal 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
|
||||
}
|
||||
226
crates/erp-health/src/handler/appointment_handler.rs
Normal file
226
crates/erp-health/src/handler/appointment_handler.rs
Normal 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()))
|
||||
}
|
||||
142
crates/erp-health/src/handler/consultation_handler.rs
Normal file
142
crates/erp-health/src/handler/consultation_handler.rs
Normal 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()))
|
||||
}
|
||||
136
crates/erp-health/src/handler/doctor_handler.rs
Normal file
136
crates/erp-health/src/handler/doctor_handler.rs
Normal 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()))
|
||||
}
|
||||
178
crates/erp-health/src/handler/follow_up_handler.rs
Normal file
178
crates/erp-health/src/handler/follow_up_handler.rs
Normal 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()))
|
||||
}
|
||||
408
crates/erp-health/src/handler/health_data_handler.rs
Normal file
408
crates/erp-health/src/handler/health_data_handler.rs
Normal 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()))
|
||||
}
|
||||
6
crates/erp-health/src/handler/mod.rs
Normal file
6
crates/erp-health/src/handler/mod.rs
Normal 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;
|
||||
311
crates/erp-health/src/handler/patient_handler.rs
Normal file
311
crates/erp-health/src/handler/patient_handler.rs
Normal 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()))
|
||||
}
|
||||
11
crates/erp-health/src/lib.rs
Normal file
11
crates/erp-health/src/lib.rs
Normal 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;
|
||||
322
crates/erp-health/src/module.rs
Normal file
322
crates/erp-health/src/module.rs
Normal 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
|
||||
}
|
||||
}
|
||||
120
crates/erp-health/src/service/appointment_service.rs
Normal file
120
crates/erp-health/src/service/appointment_service.rs
Normal 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!()
|
||||
}
|
||||
83
crates/erp-health/src/service/consultation_service.rs
Normal file
83
crates/erp-health/src/service/consultation_service.rs
Normal 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!()
|
||||
}
|
||||
161
crates/erp-health/src/service/follow_up_service.rs
Normal file
161
crates/erp-health/src/service/follow_up_service.rs
Normal 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!()
|
||||
}
|
||||
207
crates/erp-health/src/service/health_data_service.rs
Normal file
207
crates/erp-health/src/service/health_data_service.rs
Normal 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!()
|
||||
}
|
||||
6
crates/erp-health/src/service/mod.rs
Normal file
6
crates/erp-health/src/service/mod.rs
Normal 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;
|
||||
179
crates/erp-health/src/service/patient_service.rs
Normal file
179
crates/erp-health/src/service/patient_service.rs
Normal 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!()
|
||||
}
|
||||
22
crates/erp-health/src/service/seed.rs
Normal file
22
crates/erp-health/src/service/seed.rs
Normal 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(())
|
||||
}
|
||||
8
crates/erp-health/src/state.rs
Normal file
8
crates/erp-health/src/state.rs
Normal 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,
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"按钮
|
||||
- [ ] 点击生成 → 下载 PDF,SLA 承诺正确
|
||||
- [ ] `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 声明)
|
||||
@@ -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 1(settings + trigger_events + cascade + visible_when + validation)
|
||||
P2: itops Layer 1(settings + trigger_events + cascade + visible_when + validation)
|
||||
P3: freelance Layer 3(3 个 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` 字段 + 前端渲染组件。
|
||||
@@ -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_date,assigned_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 端点测试
|
||||
- 多租户隔离验证
|
||||
- 端到端功能验证
|
||||
@@ -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, itops(WASM)
|
||||
```
|
||||
|
||||
**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 Model,Fuel 限制 |
|
||||
|
||||
## 插件扩展架构
|
||||
### 集成契约
|
||||
|
||||
### 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 引用,精简技术选型表 |
|
||||
|
||||
123
wiki/database.md
123
wiki/database.md
@@ -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) |
|
||||
|
||||
128
wiki/erp-core.md
128
wiki/erp-core.md
@@ -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 当前为内存 broadcast,outbox 持久化通过后台任务实现
|
||||
|
||||
## 5. 变更记录
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-04-23 | 重构为 5 节结构,更新为已完全集成状态 |
|
||||
|
||||
122
wiki/erp-health.md
Normal file
122
wiki/erp-health.md
Normal 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 页,设计规格确认 |
|
||||
@@ -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 节结构,更新为当前集成状态 |
|
||||
|
||||
152
wiki/frontend.md
152
wiki/frontend.md
@@ -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 节结构,更新为当前完整前端状态 |
|
||||
|
||||
124
wiki/index.md
124
wiki/index.md
@@ -1,95 +1,71 @@
|
||||
# ERP 平台底座 — 知识库
|
||||
# HMS 健康管理平台 — 知识库
|
||||
|
||||
## 项目画像
|
||||
> **Health Management System (HMS)** — 面向体检中心/医疗机构的综合健康管理平台。从 ERP 底座分叉,继承身份权限/工作流/消息/配置等基础能力,`erp-health` 原生模块承载医疗业务。
|
||||
|
||||
**模块化 SaaS ERP 底座**,Rust + React 技术栈,提供身份权限/工作流/消息/配置/插件五大基础模块,支持行业业务模块快速插接。
|
||||
## 关键数字
|
||||
|
||||
关键数字:
|
||||
- 13 个 Rust crate(9 个已实现 + 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_tree),JWT 中间件注入 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` |
|
||||
|
||||
@@ -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 节结构,确立为连接信息的单一真相源 |
|
||||
|
||||
294
wiki/testing.md
294
wiki/testing.md
@@ -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 均 100,LCP 840ms,CLS 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 请求 500(CRM 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.9MB(gzip 后约 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 审计 + 性能优化 |
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
// 插件导出的 API(Host 调用)
|
||||
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 API(bindgen 生成)
|
||||
use crate::erp::plugin::host_api;
|
||||
// 导入 Guest trait(bindgen 生成)
|
||||
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 → 集成测试)
|
||||
|
||||
Reference in New Issue
Block a user