Files
erp/docs/superpowers/specs/2026-04-16-crm-plugin-design.md
iven 841766b168
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix(用户管理): 修复用户列表页面加载失败问题
修复用户列表页面加载失败导致测试超时的问题,确保页面元素正确渲染
2026-04-19 08:46:28 +08:00

1027 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CRM 客户管理插件设计规格
> **版本**: 1.1
> **日期**: 2026-04-16
> **状态**: Draft
> **作者**: ERP Team
> **审查**: 三组专家审查通过架构师、UX 架构师、高级开发者)+ Spec 审查修复 v1.1
---
## 1. 背景与目标
### 1.1 背景
ERP 平台底座已完成 Phase 1-6身份权限、系统配置、工作流、消息中心及 WASM 插件系统原型验证。现在需要构建**第一个行业插件**来验证插件系统在真实业务场景下的可行性。
### 1.2 目标
1. **构建可用的 CRM 插件** — 客户信息、联系人、沟通记录、分类筛选、关系图谱
2. **验证 WASM 插件系统** — 真实业务数据通过 Host API 读写 JSONB 动态表
3. **沉淀通用 UI 能力** — 新增的页面类型和组件对所有未来插件可用
4. **形成插件开发 skill** — 将 CRM 开发经验提炼为可复用的 skill加速后续插件开发
### 1.3 决策原则
- **完全配置驱动** — 插件不写前端代码,所有 UI 通过 manifest 配置,基座渲染
- **基座优先增强** — 新 UI 能力沉淀到基座组件库,所有插件共享
- **先修基座再做插件** — 确保插件开发时有可靠的地基
---
## 2. 架构决策
### 2.1 插件形式WASM 插件(非内置 crate
**理由**
- 作为第一个行业插件,需要验证 WASM 插件系统的端到端可行性
- CRM 是典型的行业模块,适合作为插件的标杆实现
- JSONB 动态表足以支撑 CRM 的数据量和查询复杂度
**放弃内置 crate 的理由**
- 内置 crate 需要编译时链接,不能动态安装/卸载
- 不符合"插件优先"的平台架构方向
- CRM 数据模型相对简单,不需要 ORM 级别的类型安全
### 2.2 UI 策略:完全配置驱动 + 基座组件库
**理由**
- 插件开发者只需写 Rust + 配置 manifest降低开发门槛
- 所有插件共享同一套 Ant Design 组件UI 一致性有天然保证
- 基座组件库像滚雪球,每个插件的需求都在丰富基座能力
- 避免"每个插件一套前端代码"的维护灾难
**新增的通用页面类型对后续插件的价值**
| 页面类型 | CRM 用途 | 进销存 | 生产 | 财务 |
|----------|---------|--------|------|------|
| CRUD 增强 | 客户/联系人/沟通记录 | 商品/订单/出入库 | 工单/BOM/工序 | 账单/发票/凭证 |
| detail | 客户 360° 详情 | 商品详情(含库存) | 工单详情(含进度) | 账单详情(含明细) |
| tree | 客户层级 | 商品分类 | BOM 结构 | 科目体系 |
| timeline | 沟通时间线 | 出入库记录 | 生产进度 | 交易流水 |
| tabs | 页面分组 | 页面分组 | 页面分组 | 页面分组 |
| graph | 客户关系图谱 | 供应链关系 | 工序关系 | — |
| dashboard | 客户统计 | 库存概览 | 产能概览 | 收支概览 |
---
## 3. 数据模型
### 3.1 实体总览
5 个 JSONB 动态表,表名格式 `plugin_erp_crm_{entity_name}`
| 实体 | 表名 | 用途 |
|------|------|------|
| customer | plugin_erp_crm_customer | 客户信息(个人/企业) |
| contact | plugin_erp_crm_contact | 联系人 |
| communication | plugin_erp_crm_communication | 沟通记录 |
| customer_tag | plugin_erp_crm_customer_tag | 客户标签 |
| customer_relationship | plugin_erp_crm_customer_relationship | 客户关系 |
所有实体共享标准字段(由动态表自动创建):`id`, `tenant_id`, `data`(JSONB), `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
> **注意**:表名由 `DynamicTableManager::table_name(plugin_id, entity_name)` 自动生成,格式为 `plugin_{sanitized_plugin_id}_{sanitized_entity_name}`其中非字母数字字符会被替换为下划线。CRM 插件 id 为 `erp-crm`,因此表名为 `plugin_erp_crm_*`。
### 3.2 客户customer
| 字段 | 类型 | 必填 | 唯一 | 可搜索 | 可筛选 | 条件显示 | 说明 |
|------|------|------|------|--------|--------|----------|------|
| code | String | 是 | 是 | 是 | — | — | 客户编码 |
| name | String | 是 | — | 是 | — | — | 客户名称 |
| customer_type | String(select) | 是 | — | — | 是 | — | 类型enterprise/personal |
| industry | String | 否 | — | — | 是 | — | 行业 |
| region | String | 否 | — | — | 是 | — | 地区 |
| source | String(select) | 否 | — | — | — | — | 来源referral/ad/exhibition/other |
| level | String(select) | 否 | — | — | 是 | — | 等级potential/normal/vip/svip |
| status | String(select) | 是 | — | — | 是 | — | 状态active/inactive/blacklist |
| credit_code | String | 否 | — | — | — | `customer_type == 'enterprise'` | 统一社会信用代码 |
| id_number | String | 否 | — | — | — | `customer_type == 'personal'` | 身份证号 |
| parent_id | Uuid | 否 | — | — | — | — | 上级客户 ID层级关系 |
| website | String | 否 | — | — | — | — | 网站 |
| address | String | 否 | — | — | — | — | 地址 |
| remark | String(textarea) | 否 | — | — | — | — | 备注 |
### 3.3 联系人contact
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| customer_id | Uuid | 是 | 所属客户 |
| name | String | 是 | 姓名 |
| position | String | 否 | 职务 |
| department | String | 否 | 部门 |
| phone | String | 否 | 手机号 |
| email | String | 否 | 邮箱 |
| wechat | String | 否 | 微信号 |
| is_primary | Boolean | 否 | 是否主联系人 |
| remark | String | 否 | 备注 |
### 3.4 沟通记录communication
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| customer_id | Uuid | 是 | 关联客户 |
| contact_id | Uuid | 否 | 关联联系人 |
| type | String(select) | 是 | 类型phone/email/meeting/visit/other |
| subject | String | 是 | 主题 |
| content | String(textarea) | 是 | 内容 |
| occurred_at | DateTime | 是 | 沟通时间 |
| next_follow_up | Date | 否 | 下次跟进日期 |
### 3.5 客户标签customer_tag
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| customer_id | Uuid | 是 | 关联客户 |
| tag_name | String | 是 | 标签名称 |
| tag_category | String | 否 | 标签分类industry/region/source/custom |
### 3.6 客户关系customer_relationship
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| from_customer_id | Uuid | 是 | 源客户 |
| to_customer_id | Uuid | 是 | 目标客户 |
| relationship_type | String(select) | 是 | 类型parent_child/sibling/partner/supplier/competitor |
| description | String | 否 | 关系描述 |
---
## 4. Manifest 配置
### 4.1 完整 plugin.toml
```toml
[metadata]
id = "erp-crm"
name = "客户管理"
version = "0.1.0"
description = "客户关系管理插件 — ERP 平台第一个行业插件"
author = "ERP Team"
min_platform_version = "0.1.0"
# ── 权限声明 ──
[[permissions]]
code = "crm.customer.list"
name = "查看客户"
description = "查看客户列表和详情"
[[permissions]]
code = "crm.customer.manage"
name = "管理客户"
description = "创建、编辑、删除客户"
[[permissions]]
code = "crm.contact.list"
name = "查看联系人"
[[permissions]]
code = "crm.contact.manage"
name = "管理联系人"
[[permissions]]
code = "crm.communication.list"
name = "查看沟通记录"
[[permissions]]
code = "crm.communication.manage"
name = "管理沟通记录"
[[permissions]]
code = "crm.tag.manage"
name = "管理客户标签"
[[permissions]]
code = "crm.relationship.list"
name = "查看客户关系"
[[permissions]]
code = "crm.relationship.manage"
name = "管理客户关系"
# ── 实体定义 ──
[[schema.entities]]
name = "customer"
display_name = "客户"
[[schema.entities.fields]]
name = "code"
field_type = "String"
required = true
display_name = "客户编码"
unique = true
searchable = true
[[schema.entities.fields]]
name = "name"
field_type = "String"
required = true
display_name = "客户名称"
searchable = true
[[schema.entities.fields]]
name = "customer_type"
field_type = "String"
required = true
display_name = "客户类型"
ui_widget = "select"
filterable = true
options = [
{ label = "企业", value = "enterprise" },
{ label = "个人", value = "personal" }
]
[[schema.entities.fields]]
name = "industry"
field_type = "String"
display_name = "行业"
filterable = true
[[schema.entities.fields]]
name = "region"
field_type = "String"
display_name = "地区"
filterable = true
[[schema.entities.fields]]
name = "source"
field_type = "String"
display_name = "来源"
ui_widget = "select"
options = [
{ label = "推荐", value = "referral" },
{ label = "广告", value = "ad" },
{ label = "展会", value = "exhibition" },
{ label = "主动联系", value = "outreach" },
{ label = "其他", value = "other" }
]
[[schema.entities.fields]]
name = "level"
field_type = "String"
display_name = "等级"
ui_widget = "select"
filterable = true
options = [
{ label = "潜在客户", value = "potential" },
{ label = "普通客户", value = "normal" },
{ label = "VIP", value = "vip" },
{ label = "SVIP", value = "svip" }
]
[[schema.entities.fields]]
name = "status"
field_type = "String"
required = true
display_name = "状态"
ui_widget = "select"
filterable = true
options = [
{ label = "活跃", value = "active" },
{ label = "停用", value = "inactive" },
{ label = "黑名单", value = "blacklist" }
]
[[schema.entities.fields]]
name = "credit_code"
field_type = "String"
display_name = "统一社会信用代码"
visible_when = "customer_type == 'enterprise'"
[[schema.entities.fields]]
name = "id_number"
field_type = "String"
display_name = "身份证号"
visible_when = "customer_type == 'personal'"
[[schema.entities.fields]]
name = "parent_id"
field_type = "Uuid"
display_name = "上级客户"
[[schema.entities.fields]]
name = "website"
field_type = "String"
display_name = "网站"
[[schema.entities.fields]]
name = "address"
field_type = "String"
display_name = "地址"
[[schema.entities.fields]]
name = "remark"
field_type = "String"
display_name = "备注"
ui_widget = "textarea"
[[schema.entities]]
name = "contact"
display_name = "联系人"
[[schema.entities.fields]]
name = "customer_id"
field_type = "Uuid"
required = true
display_name = "所属客户"
[[schema.entities.fields]]
name = "name"
field_type = "String"
required = true
display_name = "姓名"
searchable = true
[[schema.entities.fields]]
name = "position"
field_type = "String"
display_name = "职务"
[[schema.entities.fields]]
name = "department"
field_type = "String"
display_name = "部门"
[[schema.entities.fields]]
name = "phone"
field_type = "String"
display_name = "手机号"
[[schema.entities.fields]]
name = "email"
field_type = "String"
display_name = "邮箱"
[[schema.entities.fields]]
name = "wechat"
field_type = "String"
display_name = "微信号"
[[schema.entities.fields]]
name = "is_primary"
field_type = "Boolean"
display_name = "主联系人"
[[schema.entities.fields]]
name = "remark"
field_type = "String"
display_name = "备注"
[[schema.entities]]
name = "communication"
display_name = "沟通记录"
[[schema.entities.fields]]
name = "customer_id"
field_type = "Uuid"
required = true
display_name = "关联客户"
[[schema.entities.fields]]
name = "contact_id"
field_type = "Uuid"
display_name = "关联联系人"
[[schema.entities.fields]]
name = "type"
field_type = "String"
required = true
display_name = "类型"
ui_widget = "select"
filterable = true
options = [
{ label = "电话", value = "phone" },
{ label = "邮件", value = "email" },
{ label = "会议", value = "meeting" },
{ label = "拜访", value = "visit" },
{ label = "其他", value = "other" }
]
[[schema.entities.fields]]
name = "subject"
field_type = "String"
required = true
display_name = "主题"
searchable = true
[[schema.entities.fields]]
name = "content"
field_type = "String"
required = true
display_name = "内容"
ui_widget = "textarea"
[[schema.entities.fields]]
name = "occurred_at"
field_type = "DateTime"
required = true
display_name = "沟通时间"
sortable = true
[[schema.entities.fields]]
name = "next_follow_up"
field_type = "Date"
display_name = "下次跟进日期"
[[schema.entities]]
name = "customer_tag"
display_name = "客户标签"
[[schema.entities.fields]]
name = "customer_id"
field_type = "Uuid"
required = true
display_name = "关联客户"
[[schema.entities.fields]]
name = "tag_name"
field_type = "String"
required = true
display_name = "标签名称"
searchable = true
[[schema.entities.fields]]
name = "tag_category"
field_type = "String"
display_name = "标签分类"
ui_widget = "select"
options = [
{ label = "行业", value = "industry" },
{ label = "地区", value = "region" },
{ label = "来源", value = "source" },
{ label = "自定义", value = "custom" }
]
[[schema.entities]]
name = "customer_relationship"
display_name = "客户关系"
[[schema.entities.fields]]
name = "from_customer_id"
field_type = "Uuid"
required = true
display_name = "源客户"
[[schema.entities.fields]]
name = "to_customer_id"
field_type = "Uuid"
required = true
display_name = "目标客户"
[[schema.entities.fields]]
name = "relationship_type"
field_type = "String"
required = true
display_name = "关系类型"
ui_widget = "select"
filterable = true
options = [
{ label = "母子公司", value = "parent_child" },
{ label = "兄弟公司", value = "sibling" },
{ label = "合作伙伴", value = "partner" },
{ label = "供应商", value = "supplier" },
{ label = "竞争对手", value = "competitor" }
]
[[schema.entities.fields]]
name = "description"
field_type = "String"
display_name = "关系描述"
# ── 页面声明 ──
[[ui.pages]]
type = "tabs"
label = "客户管理"
icon = "team"
[[ui.pages.tabs]]
label = "客户列表"
type = "crud"
entity = "customer"
enable_search = true
enable_views = ["table"]
[[ui.pages.tabs]]
label = "客户层级"
type = "tree"
entity = "customer"
id_field = "id"
parent_field = "parent_id"
label_field = "name"
[[ui.pages]]
type = "detail"
entity = "customer"
label = "客户详情"
[[ui.pages.sections]]
type = "fields"
label = "基本信息"
fields = ["code", "name", "customer_type", "industry", "region", "level", "status", "credit_code", "id_number", "website", "address", "remark"]
[[ui.pages.sections]]
type = "crud"
label = "联系人"
entity = "contact"
filter_field = "customer_id"
[[ui.pages.sections]]
type = "crud"
label = "沟通记录"
entity = "communication"
filter_field = "customer_id"
enable_views = ["table", "timeline"]
[[ui.pages]]
type = "crud"
entity = "contact"
label = "联系人"
icon = "user"
enable_search = true
[[ui.pages]]
type = "crud"
entity = "communication"
label = "沟通记录"
icon = "message"
enable_search = true
enable_views = ["table", "timeline"]
[[ui.pages]]
type = "crud"
entity = "customer_tag"
label = "标签管理"
icon = "tags"
[[ui.pages]]
type = "crud"
entity = "customer_relationship"
label = "客户关系"
icon = "apartment"
# ── 事件订阅 ──
# CRM V1 不订阅任何事件,避免启动不必要的监听 task
```
### 4.2 Manifest Schema 扩展点
在现有 `PluginManifest`/`PluginField`/`PluginPage` 基础上新增的字段:
**PluginField 扩展:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `searchable` | `Option<bool>` | 标记为可搜索字段,前端渲染搜索框 |
| `filterable` | `Option<bool>` | 标记为可筛选字段,前端渲染筛选下拉 |
| `sortable` | `Option<bool>` | 标记为可排序字段 |
| `visible_when` | `Option<String>` | 条件显示表达式,如 `customer_type == 'enterprise'` |
**`visible_when` 表达式语法定义**
仅支持等值比较,格式为 `{field_name} == '{value}'`,用正则 `/^(\w+)\s*==\s*'([^']*)'$/` 解析。不支持不等、大于小于、逻辑运算。前端用 `Form.useWatch` 监听字段变化,匹配时显示、不匹配时隐藏。
**PluginPage 扩展:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `page_type` | `Option<String>` | 页面类型crud/tree/graph/timeline/tabs/detail。TOML 中使用 `type`Rust 结构体中映射为 `page_type`(避免关键字冲突),反序列化时用 `#[serde(rename = "type")]` |
**新增 PluginSection 结构(类型安全设计)**
使用 Rust enum 表达不同类型的 section通过 `#[serde(tag = "type")]` 实现 TOML 反序列化:
```rust
#[serde(tag = "type")]
pub enum PluginSection {
#[serde(rename = "fields")]
Fields {
label: String,
fields: Vec<String>,
},
#[serde(rename = "crud")]
Crud {
label: String,
entity: String,
filter_field: Option<String>,
enable_views: Option<Vec<String>>,
},
}
```
同样,`PluginPage` 使用 `#[serde(tag = "type")]` 区分不同页面类型,每种类型只包含自己需要的字段。验证规则:`crud` 类型 `entity` 必填,`tree` 类型 `id_field`/`parent_field`/`label_field` 必填,`detail` 类型 `sections` 必填,`tabs` 类型 `tabs` 必填。
| `tabs` | `Option<Vec<PluginPage>>` | tabs 类型的子页面列表 |
| `sections` | `Option<Vec<PluginSection>>` | detail 类型的区段列表 |
| `enable_search` | `Option<bool>` | 启用搜索 |
| `enable_views` | `Option<Vec<String>>` | 可用视图模式table/timeline |
| `id_field` | `Option<String>` | tree 类型ID 字段名 |
| `parent_field` | `Option<String>` | tree 类型:父级字段名 |
| `label_field` | `Option<String>` | tree/graph 类型:标签字段名 |
| `filter_field` | `Option<String>` | detail 内嵌 CRUD过滤字段名 |
**新增 PluginSection 结构:**
```rust
// 已被上方 PluginSection enum 替代
```
所有新增字段均为 `Option<T>`,不破坏现有 manifest 解析。
---
## 5. 基座增强
### 5.1 Bug 修复
#### 5.1.1 唯一索引 Bug
**文件**`crates/erp-plugin/src/dynamic_table.rs`
**问题**`unique = true` 的字段创建的是 `CREATE INDEX` 而非 `CREATE UNIQUE INDEX`。INSERT 时也未检查唯一性冲突。
**修复**
1. DDL 层面将 `unique = true` 的索引改为 `CREATE UNIQUE INDEX`,由 PostgreSQL 保证数据完整性
2. INSERT 时捕获 PostgreSQL 唯一约束违反错误error code `23505`),转换为对用户友好的冲突错误
3. **禁止**使用应用层的"先 SELECT 再 INSERT"模式,避免并发竞态条件
#### 5.1.2 插件权限未注册 + 数据 Handler 动态权限检查
**文件**`crates/erp-plugin/src/service.rs` + `handler/data_handler.rs`
**问题 A**`install()` 方法未将 manifest 声明的 permissions 写入 `permissions` 表。`check-permission` Host API 永远返回 false。
**问题 B**`data_handler.rs` 中权限检查硬编码为 `require_permission(&ctx, "plugin.list")` / `"plugin.admin"`,不支持按插件/实体/操作区分权限。
**修复**
1. `install()` 中遍历 `manifest.permissions`INSERT 到 `permissions` 表(带 `tenant_id`
2. `uninstall()` 中清理插件注册的权限
3. 权限码格式:`{plugin_id}.{code}`,如 `erp-crm.customer.list`
4. **数据 Handler 改造**`data_handler.rs` 的权限检查从硬编码改为动态计算:
- 从请求路径提取 `plugin_id``entity_name`
- 从 AppState 查找插件的 manifest确定实体对应的权限码
- 映射规则list → `{prefix}.list`create/update/delete → `{prefix}.manage`
- 调用 `require_permission(&ctx, &computed_permission_code)` 动态检查
#### 5.1.3 过滤查询缺失
**文件**`crates/erp-plugin/src/dynamic_table.rs` + `data_service.rs` + `data_handler.rs`
**问题**REST 列表接口只支持分页,不支持按 JSONB 字段过滤、搜索、排序。
**修复**
1. `DynamicTableManager` 新增 `build_filtered_query_sql` 方法
2. SQL 使用参数化查询:`WHERE tenant_id = $1 AND deleted_at IS NULL AND data->>'customer_id' = $2`
3. **安全要求**
- filter JSON 的 key 必须通过 `sanitize_identifier` 校验(只允许 ASCII 字母、数字、下划线),防止 SQL 注入
- filter JSON 的 value 必须通过参数化查询(`$N` 占位符)传入,不可拼接到 SQL 中
- search 关键词需转义 SQL LIKE 通配符(`%``\%``_``\_`
4. `PluginDataListParams` 增加 `filter``search``sort_by``sort_order` 参数
5. 前端 API 调用传递 `?filter={"customer_id":"xxx"}&search=关键词`
6. `searchable` 字段自动创建 GIN 索引
### 5.2 Host API 增强
| API | 优先级 | 说明 |
|-----|--------|------|
| `db-query` 过滤实现 | P0 | 已有接口签名,只需实现 filter 参数的 SQL 构建 |
| `db-get-by-id` | P1 | 按 ID 查单条记录 |
| `db-count` | P1 | 计数查询,用于 dashboard 统计 |
| `db-exists` | P2 | 存在性检查 |
| `db-query-batch` | P2 | 批量外键查询,解决 N+1 问题 |
| `db-query-tree` | P3 | PostgreSQL WITH RECURSIVE 递归树查询 |
| `db-aggregate` | P3 | 聚合查询COUNT/SUM/AVG |
Phase 1-2 只需 P0 和 P1。P2-P3 留到 Phase 3 或后续插件需要时再实现。
### 5.3 前端通用页面类型
#### 5.3.1 CRUD 增强Phase 1
扩展现有 `PluginCRUDPage`
- **筛选栏**:从 schema 中提取 `filterable = true` 的字段,渲染对应的 Select/Input 组件
- **搜索框**`enable_search = true` 时在表格上方显示搜索输入框
- **排序**`sortable = true` 的字段在表格列头显示排序图标
- **视图切换**`enable_views` 包含多个值时,右上角显示 `Segmented` 切换按钮
#### 5.3.2 detail 页面类型Phase 1
- **路由**:不新增独立路由。当 manifest 中声明了某实体的 `detail` 页面时CRUD 列表页自动在操作列插入"详情"按钮
- **渲染**:点击"详情"按钮打开 Drawer不需要 URL 变化),传递选中记录的 ID
- **结构**Ant Design `Drawer` + `Tabs`
- **sections 类型**
- `fields`:用 `Descriptions` 组件展示字段值
- `crud`:嵌套 CRUD 表格,通过 `filter_field` 自动过滤关联数据
- **条件表单**`visible_when` 条件字段用 `Form.useWatch` 监听变化,动态显示/隐藏
#### 5.3.3 tree 页面类型Phase 2
- 组件Ant Design `Tree` / `DirectoryTree`
- 配置:`id_field`, `parent_field`, `label_field`
- 数据加载REST API 加载全量数据,前端构建树结构
- 交互:展开/折叠、点击节点在右侧显示详情面板
#### 5.3.4 timeline 视图模式Phase 2
- 不作为独立页面类型,而是 CRUD 页面的视图切换选项
- 组件Ant Design `Timeline`
- 配置:`date_field`, `title_field`, `content_field`, `type_field`
- 与表格共享筛选和分页状态
#### 5.3.5 tabs 容器Phase 2
- 组件Ant Design `Tabs`
- 每个 tab 递归匹配子页面类型
- **侧边栏集成**`tabs` 类型在侧边栏只显示一个菜单项(使用外层的 label 和 icon点击进入后展示多个 tab
- **路由规则**`tabs` 类型注册路由 `/plugins/{pluginId}/{pageRoute}`,内部 tab 切换不改变 URL
- **动态菜单生成**:从 manifest 的 `ui.pages` 遍历生成菜单项,`tabs` 类型聚合为一个菜单项,其他类型各生成一个菜单项
#### 5.3.6 graph 页面类型Phase 3
- 组件AntV G6dynamic import 按需加载)
- 模式:以选中客户为中心,展示 1 跳关系,点击扩展
- 配置:`source_entity`, `source_field`, `target_field`, `edge_label_field`, `node_label_field`
- 交互:节点点击查看详情、拖拽、缩放、关系类型筛选
#### 5.3.7 dashboard 页面类型Phase 3
- 组件:`@ant-design/charts` + Ant Design `Statistic` / `Card`
- 需配合 `db-count` / `db-aggregate` Host API
- 配置:声明统计卡片(字段、聚合方式、图表类型)
---
## 6. WASM 插件逻辑
### 6.1 init()
```rust
fn init() -> Result<(), String> {
// CRM 插件初始化:
// - 当前无需创建默认数据
// - init() 仅做基本状态检查
// - Fuel 消耗极低(< 10 万),远在 1000 万限制内
Ok(())
}
```
### 6.2 on_tenant_created()
```rust
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
// 为新租户创建 CRM 默认数据:
// - 无需创建默认客户,租户自行录入
// - 可选:创建默认标签分类
Ok(())
}
```
### 6.3 handle_event()
CRM V1 不订阅任何事件。manifest 中的 `events` 部分留空或省略,避免启动不必要的 tokio 事件监听 task。后续版本可按需添加。
```rust
fn handle_event(event_type: String, payload: Vec<u8>) -> Result<(), String> {
// CRM V1: 无事件处理
Ok(())
}
```
CRM 插件的 WASM 逻辑相对简单,主要是数据初始化和简单的事件响应。复杂查询通过 REST API 在前端直接完成,不经过 WASM。
---
## 7. 权限模型
### 7.1 权限码
9 个权限码,遵循 `{plugin_id}.{entity}.{action}` 格式:
| 权限码 | 名称 | 说明 |
|--------|------|------|
| `crm.customer.list` | 查看客户 | 列表和详情 |
| `crm.customer.manage` | 管理客户 | 创建/编辑/删除 |
| `crm.contact.list` | 查看联系人 | — |
| `crm.contact.manage` | 管理联系人 | — |
| `crm.communication.list` | 查看沟通记录 | — |
| `crm.communication.manage` | 管理沟通记录 | — |
| `crm.tag.manage` | 管理客户标签 | — |
| `crm.relationship.list` | 查看客户关系 | — |
| `crm.relationship.manage` | 管理客户关系 | — |
### 7.2 权限注册
- `install()` 时写入 `permissions` 表,关联 `tenant_id`
- `uninstall()` 时清理
- 数据 CRUD 的 REST API 通过 `check-permission` 检查对应权限
- `detail` 页面的关联实体同样受权限控制
---
## 8. 实施分期
### Phase 1基座增强前置条件
**目标**:修复 Bug + 增强查询能力 + 扩展 manifest + 新增 detail 页面类型
交付物:
1. 修复唯一索引 Bug`dynamic_table.rs`
2. 修复权限注册 Bug`service.rs`+ 数据 Handler 动态权限检查(`data_handler.rs`
3. REST API 支持过滤/搜索/排序(`dynamic_table.rs` + `data_service.rs` + `data_handler.rs`
4. Manifest schema 扩展:`PluginField` 增加 searchable/filterable/sortable/visible_when`PluginPage` 增加 page_type/tabs/sections 等
5. 前端 CRUD 增强:筛选栏 + 搜索框 + 排序 + API 参数扩展
6. 前端 detail 页面类型Drawer + Tabs + 嵌套 CRUD
7. 前端 visible_when 条件表单字段
8. 数据校验层required 检查 + 类型检查)
**验收标准**
- 唯一字段插入重复值返回冲突错误
- 插件安装后权限在数据库中可见
- REST API 支持 `?filter={"field":"value"}&search=keyword&sort_by=name`
- PluginCRUDPage 自动渲染筛选栏和搜索框
- 点击行打开 detail Drawer关联实体自动过滤
- 企业/个人客户的差异化字段根据类型动态显示/隐藏
### Phase 2CRM 插件核心
**目标**:实现完整的 CRM WASM 插件 + tree/timeline/tabs 页面类型
交付物(依赖 Phase 1 全部完成):
1. CRM WASM 插件 Rust 代码init/on_tenant_created/handle_event
2. CRM plugin.toml manifest5 个实体 + 页面声明 + 权限)
3. 前端 tree 页面类型Ant Design Tree
4. 前端 timeline 视图模式Ant Design Timeline + Segmented 切换)
5. 前端 tabs 容器页面类型
6. 侧边栏动态菜单集成manifest pages → 路由 → 菜单项)
**验收标准**
- 上传 CRM 插件 WASM → 安装 → 启用,侧边栏出现 CRM 菜单
- 客户 CRUD 完整可用(创建/列表/详情/编辑/删除)
- 联系人按客户自动过滤,沟通记录按客户自动过滤
- 客户层级树正确展示
- 沟通记录可切换表格/时间线视图
- 个人/企业客户差异化字段正常工作
### Phase 3高级功能
**目标**:关系图谱 + 统计概览 + Host API 扩展
交付物(依赖 Phase 2 全部完成):
1. 前端 graph 页面类型AntV G6
2. 前端 dashboard 页面类型(@ant-design/charts
3. Host API 新增 db-count / db-aggregate
4. 关系图谱按中心节点展开模式
5. 客户统计概览(数量/分类分布/等级分布)
6. 插件版本升级策略规划
---
## 9. 插件开发 Skill 提炼计划
CRM 插件开发完成后,将开发经验提炼为可复用的 skill供后续插件开发使用。
### 9.1 Skill 内容
1. **插件开发流程指南** — 从需求分析到上线的完整步骤
2. **Manifest 模板** — 各页面类型的配置模板和示例
3. **Rust 插件脚手架** — init/on_tenant_created/handle_event 的标准实现
4. **可用页面类型清单** — 每种类型的能力、配置项、限制
5. **测试检查清单** — 插件发布前的必检项
### 9.2 Skill 触发场景
- 用户说"开发一个新插件"或"新建行业模块"时自动触发
- 提供页面类型选择、manifest 生成、Rust 代码生成的向导式流程
---
## 10. 关键文件清单
### 基座修改文件
| 文件 | 改动类型 | Phase |
|------|----------|-------|
| `crates/erp-plugin/src/dynamic_table.rs` | Bug 修复 + 查询增强 | 1 |
| `crates/erp-plugin/src/service.rs` | Bug 修复(权限注册) | 1 |
| `crates/erp-plugin/src/data_service.rs` | 过滤查询支持 | 1 |
| `crates/erp-plugin/src/handler/data_handler.rs` | REST API 参数扩展 | 1 |
| `crates/erp-plugin/src/manifest.rs` | Schema 扩展 | 1 |
| `crates/erp-plugin/src/host.rs` | Host API 过滤实现 | 1 |
| `crates/erp-plugin/src/dto.rs` | DTO 扩展filter/search 参数) | 1 |
| `apps/web/src/pages/PluginCRUDPage.tsx` | CRUD 增强 | 1 |
| `apps/web/src/api/pluginData.ts` | API 参数扩展filter/search/sort | 1 |
| `apps/web/src/pages/PluginDetailPage.tsx` | 新增 detail 页面 | 1 |
| `apps/web/src/pages/PluginTreePage.tsx` | 新增 tree 页面 | 2 |
| `apps/web/src/pages/PluginTimelineView.tsx` | 新增 timeline 视图 | 2 |
| `apps/web/src/pages/PluginTabsPage.tsx` | 新增 tabs 容器 | 2 |
### CRM 插件新建文件
| 文件 | 说明 | Phase |
|------|------|-------|
| `crates/erp-plugin-crm/Cargo.toml` | 插件 crate 配置 | 2 |
| `crates/erp-plugin-crm/src/lib.rs` | 插件入口WASM Guest | 2 |
| `crates/erp-plugin-crm/plugin.toml` | 插件 manifest | 2 |
**CRM 插件 Cargo.toml 模板**(参考 `erp-plugin-test-sample`
```toml
[package]
name = "erp-plugin-crm"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.33"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
```
**前端 API 扩展**`apps/web/src/api/pluginData.ts`
```typescript
export async function listPluginData(
pluginId: string,
entity: string,
page = 1,
pageSize = 20,
options?: {
filter?: Record<string, string>;
search?: string;
sort_by?: string;
sort_order?: 'asc' | 'desc';
}
) {
const params: Record<string, string> = { page: String(page), page_size: String(pageSize) };
if (options?.filter) params.filter = JSON.stringify(options.filter);
if (options?.search) params.search = options.search;
if (options?.sort_by) params.sort_by = options.sort_by;
if (options?.sort_order) params.sort_order = options.sort_order;
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<PluginData> }>(
`/plugins/${pluginId}/${entity}`, { params }
);
return data.data;
}
```
### 数据校验层
JSONB 动态表没有数据库层面的外键约束和字段级校验。运行时校验通过 manifest schema 驱动:
1. **`required` 校验**`data_service.rs` 的 create/update 方法检查必填字段是否存在
2. **字段类型校验**:确保值类型与 `field_type` 匹配(如 `Integer` 字段不接受字符串)
3. **扩展校验**(后续版本):`PluginField` 可选增加 `validation` 字段,支持 `pattern`(正则)、`min_length``max_length`
**已知限制**JSONB 无法保证外键引用完整性(如 `contact.customer_id` 必须指向存在的客户。Phase 1 在 `data_service.rs` 中不做外键检查Phase 2 可选增加。
---
## 11. 风险与缓解
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|----------|
| JSONB 查询性能不足 | 低 | 中 | GIN 索引 + searchable 字段自动创建索引 |
| Host API 版本不兼容 | 中 | 高 | CRM 开发前冻结 Host API v2 接口 |
| G6 包体积过大 | 中 | 低 | Dynamic import 按需加载 |
| visible_when 表达式安全 | 低 | 高 | 禁用 eval用正则 `/^(\w+)\s*==\s*'([^']*)'$/` 解析 |
| 插件权限与内置权限冲突 | 低 | 中 | 权限码加 plugin_id 前缀隔离 |
| 插件版本升级 | 中 | 中 | JSONB 天然兼容新增字段;删除/重命名字段需在 manifest 中标记废弃;索引变更需 ALTER TABLEPhase 3 规划插件升级策略 |
| JSONB 外键引用完整性 | 中 | 中 | 数据库层面无法保证Phase 1 不做外键检查Phase 2 可选在 data_service 中增加引用验证 |
---
## 12. 测试策略
CRM 插件作为第一个行业插件,其测试策略对后续插件有标杆作用。
### 12.1 Rust 侧测试
| 测试类型 | 覆盖内容 | Phase |
|----------|---------|-------|
| 单元测试 | manifest 解析新增字段、tabs 嵌套、PluginSection enum | 1 |
| 单元测试 | `visible_when` 表达式解析 | 1 |
| 单元测试 | `sanitize_identifier` 对 filter key 的安全校验 | 1 |
| 单元测试 | `build_filtered_query_sql` 参数化 SQL 构建 | 1 |
| 单元测试 | 唯一约束违反错误码 23505 的捕获和转换 | 1 |
| 集成测试 | 插件安装时权限注册到数据库 | 1 |
| 集成测试 | 过滤查询filter/search/sort端到端 | 1 |
| 集成测试 | CRM 插件 WASM 加载/初始化/数据 CRUD | 2 |
### 12.2 前端测试
| 测试类型 | 覆盖内容 | Phase |
|----------|---------|-------|
| 组件测试 | PluginCRUDPage 筛选栏/搜索框自动渲染 | 1 |
| 组件测试 | visible_when 条件字段动态显示/隐藏 | 1 |
| 组件测试 | PluginDetailPage Drawer + 嵌套 CRUD | 1 |
| 组件测试 | PluginTreePage 树形展示 | 2 |
| 组件测试 | timeline 视图模式切换 | 2 |
### 12.3 端到端测试
完整流程:上传 CRM WASM → 安装(创建动态表 + 注册权限) → 启用WASM 初始化 + 事件监听) → 数据 CRUD客户/联系人/沟通记录) → 客户详情Drawer + 关联实体) → 停用 → 卸载。