46 KiB
CRM 客户管理插件实施计划
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 实施 ERP 平台第一个行业插件——CRM 客户管理,同时增强基座的插件系统(过滤查询、动态权限、配置驱动 UI)。
Architecture: WASM 插件 + JSONB 动态表 + 配置驱动前端。先修基座(Phase 1),再建插件(Phase 2),最后高级功能(Phase 3)。
Tech Stack: Rust (Axum + SeaORM + Wasmtime) / TypeScript (React 18 + Ant Design + Zustand) / PostgreSQL (JSONB)
Design Spec: docs/superpowers/specs/2026-04-16-crm-plugin-design.md v1.1
Chunk 1: Phase 1 — Rust 后端 Bug 修复与查询增强
Task 1: 修复唯一索引 Bug
Files:
-
Modify:
crates/erp-plugin/src/dynamic_table.rs:67-87 -
Step 1: 写失败测试
在 crates/erp-plugin/src/dynamic_table.rs 底部的 #[cfg(test)] 模块中添加:
#[test]
fn test_unique_index_sql_uses_create_unique_index() {
// 验证 unique 字段生成的 SQL 包含 UNIQUE 关键字
let entity = PluginEntity {
name: "test".to_string(),
display_name: "Test".to_string(),
fields: vec![PluginField {
name: "code".to_string(),
field_type: PluginFieldType::String,
required: true,
unique: true,
default: None,
display_name: "Code".to_string(),
ui_widget: None,
options: None,
}],
indexes: vec![],
};
// 验证 create_table 生成的 SQL 中 unique 字段的索引包含 UNIQUE
let sql = DynamicTableManager::build_unique_index_sql("plugin_test", &entity.fields[0]);
assert!(sql.contains("CREATE UNIQUE INDEX"), "Expected UNIQUE index, got: {}", sql);
}
- Step 2: 运行测试确认失败
Run: cargo test -p erp-plugin -- test_unique_index_sql_uses_create_unique_index
Expected: 编译失败或测试失败(因为 build_unique_index_sql 方法尚不存在)
- Step 3: 实现修复
在 crates/erp-plugin/src/dynamic_table.rs 中:
- 在
create_table方法(第 67-87 行区域)中,将unique字段的索引创建从CREATE INDEX改为CREATE UNIQUE INDEX。修改索引创建循环中判断field.unique的分支:
// 在 create_table 方法的索引创建循环中
if field.unique {
let idx_name = format!("{}_{}_uniq", table_name, field.name);
let idx_sql = format!(
"CREATE UNIQUE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (data->>'{}')",
idx_name, table_name, field.name
);
// 执行 idx_sql...
}
- 添加辅助方法
build_unique_index_sql供测试使用:
pub fn build_unique_index_sql(table_name: &str, field: &PluginField) -> String {
let idx_name = format!("{}_{}_uniq", table_name, field.name);
format!(
"CREATE UNIQUE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (data->>'{}')",
idx_name, table_name, field.name
)
}
- 无需在 data_service.rs 手动处理 23505 错误。
erp-core/src/error.rs的From<sea_orm::DbErr> for AppError已经将duplicate key错误自动转换为AppError::Conflict("记录已存在")。data_service.rs使用?传播错误,会自动走这个转换路径。
- Step 4: 运行测试确认通过
Run: cargo test -p erp-plugin -- test_unique_index_sql_uses_create_unique_index
Expected: PASS
- Step 5: 提交
git add crates/erp-plugin/src/dynamic_table.rs crates/erp-plugin/src/data_service.rs
git commit -m "fix(plugin): 修复唯一索引使用 CREATE UNIQUE INDEX 并捕获 23505 错误"
Task 2: 修复插件权限注册(跨 crate 方案)
Files:
- Modify:
crates/erp-core/src/module.rs— ErpModule trait 新增register_permissions方法 - Modify:
crates/erp-plugin/src/module.rs— PluginModule 实现权限注册 - Modify:
crates/erp-server/src/main.rs— 模块注册时调用权限注册
架构约束:erp-plugin 不能直接依赖 erp-auth(业务 crate 间禁止直接依赖)。解决方案:通过 ErpModule trait 新增方法,在 erp-server(唯一组装点)中桥接。
- Step 1: 在 ErpModule trait 中新增权限注册方法
在 crates/erp-core/src/module.rs 的 ErpModule trait 中添加:
/// 返回此模块需要注册的权限列表,在 install/启用时由 ModuleRegistry 调用
fn permissions(&self) -> Vec<PermissionDescriptor> {
vec![]
}
/// 返回此模块需要清理的权限前缀,在 uninstall 时调用
fn permission_prefix(&self) -> Option<String> {
None
}
在 erp-core 中定义 PermissionDescriptor:
#[derive(Clone, Debug)]
pub struct PermissionDescriptor {
pub code: String,
pub name: String,
pub description: String,
pub module: String,
}
- Step 2: 在 PluginModule 中实现 permissions()
在 crates/erp-plugin/src/module.rs 中,PluginModule 实现从已加载插件的 manifest 中提取权限:
fn permissions(&self) -> Vec<PermissionDescriptor> {
// 从 engine 中获取所有已安装插件的 manifest
// 遍历 manifest.permissions,生成 PermissionDescriptor 列表
// module 字段设为 format!("plugin:{}", plugin_id)
}
- Step 3: 在 ModuleRegistry 中添加权限注册流程
在 crates/erp-core/src/module.rs 的 ModuleRegistry 中添加:
pub async fn register_permissions(&self, tenant_id: Uuid, operator_id: Uuid, db: &DatabaseConnection) -> AppResult<()> {
for module in &self.modules {
let perms = module.permissions();
for perm in perms {
// 使用 raw SQL 插入到 permissions 表
// INSERT INTO permissions (id, tenant_id, code, name, description, module, ...)
// VALUES ($1, $2, $3, $4, $5, $6, ...)
// ON CONFLICT DO NOTHING
}
}
Ok(())
}
使用 raw SQL 而非 entity 操作,避免 erp-core 依赖 erp-auth。
- Step 4: 在 install 流程中调用权限注册
在 crates/erp-plugin/src/service.rs 的 install 方法中,调用 registry.register_permissions() 注册新插件的权限。在 uninstall 中使用 raw SQL 清理。
- Step 5: 运行编译检查
Run: cargo check --workspace
Expected: 编译通过
- Step 6: 提交
git add crates/erp-core/src/module.rs crates/erp-plugin/src/module.rs crates/erp-plugin/src/service.rs
git commit -m "fix(plugin): 通过 ErpModule trait 桥接实现插件权限注册,解决跨 crate 依赖问题"
Task 3: 扩展 Manifest Schema
Files:
- Modify:
crates/erp-plugin/src/manifest.rs:49-61(PluginField) - Modify:
crates/erp-plugin/src/manifest.rs:94-109(PluginUi + PluginPage) - Modify:
crates/erp-plugin/src/service.rs(manifest 引用点) - Modify:
crates/erp-plugin/src/host.rs(manifest 引用点)
迁移策略:直接替换 PluginPage 结构体为 tagged enum PluginPageType,同步更新所有引用点。不保留旧结构体——现有 parse_full_manifest 测试使用旧的 route/entity/display_name/icon/menu_group 格式,需同步更新测试的 TOML 内容。这个 crate 还没有外部消费者,直接迁移最干净。
- Step 1: 写失败测试
在 crates/erp-plugin/src/manifest.rs 测试模块中添加:
#[test]
fn test_parse_manifest_with_new_fields() {
let toml = r#"
[metadata]
id = "test-plugin"
name = "Test"
version = "0.1.0"
description = "Test"
author = "Test"
min_platform_version = "0.1.0"
[[schema.entities]]
name = "customer"
display_name = "客户"
[[schema.entities.fields]]
name = "code"
field_type = "string"
required = true
display_name = "编码"
unique = true
searchable = true
filterable = true
visible_when = "customer_type == 'enterprise'"
[[ui.pages]]
type = "tabs"
label = "客户管理"
icon = "team"
[[ui.pages.tabs]]
label = "客户列表"
type = "crud"
entity = "customer"
enable_search = true
enable_views = ["table", "timeline"]
[[ui.pages]]
type = "detail"
entity = "customer"
label = "客户详情"
[[ui.pages.sections]]
type = "fields"
label = "基本信息"
fields = ["code", "name"]
"#;
let manifest = parse_manifest(toml).expect("should parse");
let field = &manifest.schema.as_ref().unwrap().entities[0].fields[0];
assert_eq!(field.searchable, Some(true));
assert_eq!(field.filterable, Some(true));
assert_eq!(field.visible_when.as_deref(), Some("customer_type == 'enterprise'"));
// 验证页面类型解析
let ui = manifest.ui.as_ref().unwrap();
assert_eq!(ui.pages.len(), 2);
// tabs 页面
match &ui.pages[0] {
PluginPageType::Tabs { label, tabs, .. } => {
assert_eq!(label, "客户管理");
assert_eq!(tabs.len(), 1);
}
_ => panic!("Expected Tabs page type"),
}
// detail 页面
match &ui.pages[1] {
PluginPageType::Detail { entity, sections, .. } => {
assert_eq!(entity, "customer");
assert_eq!(sections.len(), 1);
}
_ => panic!("Expected Detail page type"),
}
}
- Step 2: 运行测试确认失败
Expected: FAIL(新字段 searchable/filterable/visible_when 尚不存在,PluginPageType 尚未定义)
- Step 3: 扩展 PluginField 结构体
在 manifest.rs 的 PluginField(第 49-61 行)中新增字段。注意 display_name 现有类型是 Option<String>,保持不变:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginField {
pub name: String,
pub field_type: PluginFieldType,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub unique: bool,
pub default: Option<serde_json::Value>,
pub display_name: Option<String>, // 注意:现有是 Option<String>
pub ui_widget: Option<String>,
pub options: Option<Vec<serde_json::Value>>,
// 新增字段 — 全部 Optional + serde(default) 保证向后兼容
#[serde(default)]
pub searchable: Option<bool>,
#[serde(default)]
pub filterable: Option<bool>,
#[serde(default)]
pub sortable: Option<bool>,
#[serde(default)]
pub visible_when: Option<String>,
}
- Step 4: 替换 PluginPage 为 tagged enum PluginPageType
直接删除 PluginPage(第 100-109 行),替换为:
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum PluginPageType {
#[serde(rename = "crud")]
Crud {
entity: String,
label: String,
icon: Option<String>,
enable_search: Option<bool>,
enable_views: Option<Vec<String>>,
},
#[serde(rename = "tree")]
Tree {
entity: String,
label: String,
icon: Option<String>,
id_field: String,
parent_field: String,
label_field: String,
},
#[serde(rename = "detail")]
Detail {
entity: String,
label: String,
sections: Vec<PluginSection>,
},
#[serde(rename = "tabs")]
Tabs {
label: String,
icon: Option<String>,
tabs: Vec<PluginPageType>,
},
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[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>>,
},
}
更新 PluginUi:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginUi {
pub pages: Vec<PluginPageType>,
}
删除旧的 PluginPage 结构体(第 100-109 行)。
- Step 5: 更新所有引用点
搜索 PluginPage 的所有使用位置并更新:
service.rs—install()中如果遍历了ui.pages,更新字段访问方式(改为 match 分支)host.rs— 如果引用了PluginPage,更新为新 enumdata_handler.rs— 同上
使用 grep -rn "PluginPage" crates/erp-plugin/src/ 查找所有引用。
- Step 6: 更新现有测试
更新 parse_full_manifest 测试中的 TOML,将旧格式:
[[ui.pages]]
route = "/products"
entity = "product"
display_name = "商品管理"
icon = "ShoppingOutlined"
menu_group = "进销存"
改为新格式:
[[ui.pages]]
type = "crud"
entity = "product"
label = "商品管理"
icon = "ShoppingOutlined"
同时更新断言(display_name → label,去掉 route/menu_group)。
- Step 7: 添加页面类型验证
在 parse_manifest 中添加:
if let Some(ui) = &manifest.ui {
for page in &ui.pages {
match page {
PluginPageType::Crud { entity, .. } => {
if entity.is_empty() {
return Err(PluginError::InvalidManifest("crud page 的 entity 不能为空".into()));
}
}
PluginPageType::Tree { id_field, parent_field, label_field, .. } => {
if id_field.is_empty() || parent_field.is_empty() || label_field.is_empty() {
return Err(PluginError::InvalidManifest("tree page 的 id/parent/label_field 不能为空".into()));
}
}
PluginPageType::Detail { entity, sections } => {
if entity.is_empty() || sections.is_empty() {
return Err(PluginError::InvalidManifest("detail page 的 entity 和 sections 不能为空".into()));
}
}
PluginPageType::Tabs { tabs, .. } => {
if tabs.is_empty() {
return Err(PluginError::InvalidManifest("tabs page 的 tabs 不能为空".into()));
}
}
}
}
}
- Step 8: 运行全部测试
Run: cargo test -p erp-plugin
Expected: ALL PASS
Run: cargo check --workspace
Expected: 编译通过
- Step 9: 提交
git add crates/erp-plugin/src/
git commit -m "feat(plugin): 扩展 manifest schema 支持 searchable/filterable/visible_when 和 tagged enum 页面类型"
Task 4: 实现过滤查询 SQL 构建器
Files:
-
Modify:
crates/erp-plugin/src/dynamic_table.rs(新增方法) -
Modify:
crates/erp-plugin/src/data_dto.rs:28-33(PluginDataListParams) -
Step 1: 写失败测试
#[test]
fn test_build_filtered_query_sql_with_filter() {
let sql_result = DynamicTableManager::build_filtered_query_sql(
"plugin_test_customer",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
20,
0,
Some(serde_json::json!({"customer_id": "abc-123"})),
None,
None,
None,
);
let (sql, params) = sql_result;
assert!(sql.contains("data->>'customer_id' ="), "Expected filter in SQL, got: {}", sql);
assert!(sql.contains("tenant_id"), "Expected tenant_id filter");
}
#[test]
fn test_build_filtered_query_sql_sanitizes_keys() {
// 恶意 key 应被拒绝
let result = DynamicTableManager::build_filtered_query_sql(
"plugin_test",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
20, 0,
Some(serde_json::json!({"evil'; DROP TABLE--": "value"})),
None, None, None,
);
assert!(result.is_err() || !result.as_ref().unwrap().0.contains("DROP TABLE"));
}
#[test]
fn test_build_filtered_query_sql_with_search() {
let (sql, _) = DynamicTableManager::build_filtered_query_sql(
"plugin_test",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
20, 0, None,
Some(("name,code".to_string(), "测试关键词".to_string())),
None, None,
);
assert!(sql.contains("ILIKE"), "Expected ILIKE in SQL, got: {}", sql);
}
- Step 2: 运行测试确认失败
Run: cargo test -p erp-plugin -- test_build_filtered_query_sql
Expected: FAIL(方法不存在)
- Step 3: 扩展 PluginDataListParams
在 data_dto.rs 中:
#[derive(Debug, Deserialize, IntoParams)]
pub struct PluginDataListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub search: Option<String>,
// 新增
pub filter: Option<String>, // JSON 格式: {"field":"value"}
pub sort_by: Option<String>,
pub sort_order: Option<String>, // "asc" or "desc"
}
- Step 4: 实现 build_filtered_query_sql
在 dynamic_table.rs 中新增方法:
pub fn build_filtered_query_sql(
table_name: &str,
tenant_id: Uuid,
limit: u64,
offset: u64,
filter: Option<serde_json::Value>,
search: Option<(String, String)>, // (searchable_fields_csv, keyword)
sort_by: Option<String>,
sort_order: Option<String>,
) -> Result<(String, Vec<Value>), String> {
let mut conditions = vec![
format!("\"tenant_id\" = ${}", 1),
format!("\"deleted_at\" IS NULL"),
];
let mut param_idx = 2;
let mut values: Vec<Value> = vec![tenant_id.into()];
// 处理 filter
if let Some(f) = filter {
if let Some(obj) = f.as_object() {
for (key, val) in obj {
let clean_key = sanitize_identifier(key);
if clean_key.is_empty() {
return Err(format!("无效的过滤字段名: {}", key));
}
conditions.push(format!("\"data\"->>'{}' = ${}", clean_key, param_idx));
values.push(Value::String(Some(Box::new(val.as_str().unwrap_or("").to_string()))));
param_idx += 1;
}
}
}
// 处理 search — 所有字段共享同一个 ILIKE 参数(同一个关键词)
if let Some((fields_csv, keyword)) = search {
let escaped = keyword.replace('%', "\\%").replace('_', "\\_");
let fields: Vec<&str> = fields_csv.split(',').collect();
let search_param_idx = param_idx;
let search_conditions: Vec<String> = fields.iter().map(|f| {
let clean = sanitize_identifier(f.trim());
format!("\"data\"->>'{}' ILIKE ${}", clean, search_param_idx)
}).collect();
conditions.push(format!("({})", search_conditions.join(" OR ")));
values.push(Value::String(Some(Box::new(format!("%{}%", escaped)))));
param_idx += 1;
}
// 处理 sort
let order_clause = if let Some(sb) = sort_by {
let clean = sanitize_identifier(&sb);
let dir = match sort_order.as_deref() {
Some("asc") | Some("ASC") => "ASC",
_ => "DESC",
};
format!("ORDER BY \"data\"->>'{}' {}", clean, dir)
} else {
"ORDER BY \"created_at\" DESC".to_string()
};
let sql = format!(
"SELECT * FROM \"{}\" WHERE {} {} LIMIT ${} OFFSET ${}",
table_name,
conditions.join(" AND "),
order_clause,
param_idx,
param_idx + 1,
);
values.push(limit.into());
values.push(offset.into());
Ok((sql, values))
}
注意:Value 类型即 sea_orm::Value(文件头部已有 use sea_orm::Value)。Value::String(Some(Box::new(...))) 是 SeaORM 的 String variant 包装方式。
- Step 5: 运行测试确认通过
Run: cargo test -p erp-plugin -- test_build_filtered_query_sql
Expected: PASS
Run: cargo check -p erp-plugin
Expected: 编译通过
- Step 6: 提交
git add crates/erp-plugin/src/dynamic_table.rs crates/erp-plugin/src/data_dto.rs
git commit -m "feat(plugin): 实现过滤查询 SQL 构建器,支持 filter/search/sort"
Task 5: 集成过滤查询到 data_service 和 handler
Files:
-
Modify:
crates/erp-plugin/src/data_service.rs:57-116(list 方法) -
Modify:
crates/erp-plugin/src/handler/data_handler.rs:25-57(list handler) -
Step 1: 修改 data_service.rs 的 list 方法签名
将 list 方法签名从:
pub async fn list(plugin_id: Uuid, entity_name: &str, tenant_id: Uuid, page: u64, page_size: u64, db: &DatabaseConnection) -> AppResult<(Vec<PluginDataResp>, u64)>
改为:
pub async fn list(
plugin_id: Uuid,
entity_name: &str,
tenant_id: Uuid,
page: u64,
page_size: u64,
db: &DatabaseConnection,
filter: Option<serde_json::Value>,
search: Option<String>,
search_fields: Option<String>,
sort_by: Option<String>,
sort_order: Option<String>,
) -> AppResult<(Vec<PluginDataResp>, u64)>
在方法内部,将 DynamicTableManager::build_query_sql 替换为 build_filtered_query_sql。search_fields 参数来源:从 plugin_entities 表的 schema_json 字段反序列化出 entity 的 fields,筛选 searchable == Some(true) 的字段名,用逗号拼接成 CSV。如无可搜索字段则传 None。
// 在 list 方法内部获取 searchable fields
let entity_fields = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?;
let search_fields_csv: Option<String> = {
let searchable: Vec<&str> = entity_fields.iter()
.filter(|f| f.searchable == Some(true))
.map(|f| f.name.as_str())
.collect();
if searchable.is_empty() { None } else { Some(searchable.join(",")) }
};
let search_tuple = match (&search_fields_csv, &search) {
(Some(fields), Some(kw)) => Some((fields.clone(), kw.clone())),
_ => None,
};
let (sql, values) = DynamicTableManager::build_filtered_query_sql(
&table_name, tenant_id, page_size, offset, filter, search_tuple, sort_by, sort_order,
)?;
- Step 2: 修改 data_handler.rs 的 list handler
在 list_plugin_data 函数中,提取 Query(params) 并传递给 service。注意 handler 不需要传 search_fields——service 层自己从 manifest 查找 searchable 字段:
let filter: Option<serde_json::Value> = params.filter.as_ref()
.and_then(|f| serde_json::from_str(f).ok());
let result = PluginDataService::list(
plugin_id, &entity, ctx.tenant_id, page, page_size,
&state.db, filter, params.search, params.sort_by, params.sort_order,
).await?;
- Step 3: 修改 data_handler.rs 的动态权限检查
将所有 handler 的 require_permission 从硬编码改为动态计算:
// 辅助函数:计算插件数据操作所需的权限码
// 格式:{plugin_id}.{entity}.{action},如 crm.customer.list
fn compute_permission_code(plugin_id: &str, entity_name: &str, action: &str) -> String {
let action_suffix = match action {
"list" | "get" => "list",
_ => "manage",
};
format!("{}.{}.{}", plugin_id, entity_name, action_suffix)
}
替换每个 handler 中的权限检查:
// 原来: require_permission(&ctx, "plugin.list")?;
// 改为:
let perm = compute_permission_code(&plugin_id, &entity_name, "list");
require_permission(&ctx, &perm)?;
- Step 4: 运行编译检查
Run: cargo check -p erp-plugin
Expected: 编译通过
Run: cargo test -p erp-plugin
Expected: ALL PASS
- Step 5: 提交
git add crates/erp-plugin/src/data_service.rs crates/erp-plugin/src/handler/data_handler.rs
git commit -m "feat(plugin): 集成过滤查询到 REST API,改造动态权限检查"
Task 6: 添加 searchable 字段 GIN 索引
Files:
-
Modify:
crates/erp-plugin/src/dynamic_table.rs(create_table 方法) -
Step 1: 在 create_table 中为 searchable 字段创建 GIN 索引
在 create_table 方法的索引创建循环后,添加 searchable 字段的 GIN 索引:
// 为 searchable 字段创建 GIN 索引以加速 ILIKE 查询
for field in &entity.fields {
if field.searchable == Some(true) {
let idx_name = format!("{}_{}_gin", sanitized_table, field.name);
let gin_sql = format!(
"CREATE INDEX IF NOT EXISTS \"{}\" ON \"{}\" USING GIN (\"data\" gin_path_ops)",
idx_name, sanitized_table
);
// 执行 gin_sql...
}
}
注意:gin_path_ops 适用于 @> 操作符。对于 ILIKE 查询,可能需要 btree(data->>'field') 索引或 pg_trgm 扩展的 GIN 索引。根据实际查询模式选择合适的索引类型。
- Step 2: 运行编译检查
Run: cargo check -p erp-plugin
Expected: 编译通过
- Step 3: 提交
git add crates/erp-plugin/src/dynamic_table.rs
git commit -m "feat(plugin): 为 searchable 字段自动创建 GIN 索引"
Task 7: 添加数据校验层
Files:
-
Modify:
crates/erp-plugin/src/data_service.rs(create/update 方法) -
Step 1: 实现 resolve_entity_fields 辅助函数
在 data_service.rs 中添加——从 plugin_entities 表的 schema_json 字段获取 entity 的字段定义:
use crate::manifest::PluginField;
/// 从 plugin_entities 表获取 entity 的字段定义
async fn resolve_entity_fields(
plugin_id: Uuid,
entity_name: &str,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AppResult<Vec<PluginField>> {
let entity_model = plugin_entity::Entity::find()
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
.filter(plugin_entity::Column::EntityName.eq(entity_name))
.filter(plugin_entity::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name)))?;
let entity_def: crate::manifest::PluginEntity =
serde_json::from_value(entity_model.schema_json)
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
Ok(entity_def.fields)
}
注意:resolve_entity_fields 同时被 Task 5 的 list 方法和本 Task 的 validate_data 使用。
- Step 2: 实现 validate_data 辅助函数
fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<()> {
let obj = data.as_object()
.ok_or_else(|| AppError::Validation("data 必须是 JSON 对象".to_string()))?;
for field in fields {
if field.required && !obj.contains_key(&field.name) {
let label = field.display_name.as_deref().unwrap_or(&field.name);
return Err(AppError::Validation(format!("字段 '{}' 不能为空", label)));
}
}
Ok(())
}
注意:field.display_name 类型是 Option<String>,使用 as_deref().unwrap_or() 提供回退值。
- Step 3: 在 create/update 中调用验证
在 create 方法中(在 build_insert_sql 之前):
let fields = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?;
validate_data(&data, &fields)?;
在 update 方法中同理。
- Step 4: 运行编译检查
Run: cargo check -p erp-plugin
Expected: 编译通过
- Step 5: 提交
git add crates/erp-plugin/src/data_service.rs
git commit -m "feat(plugin): 添加数据校验层,检查 required 字段"
Chunk 2: Phase 1 — 前端 CRUD 增强、detail 页面、visible_when
Task 8: 扩展前端 pluginData API
Files:
-
Modify:
apps/web/src/api/pluginData.ts:19-30 -
Modify:
apps/web/src/api/plugins.ts:116-121 -
Step 1: 扩展 listPluginData 函数签名
将 pluginData.ts 的 listPluginData 改为:
export interface PluginDataListOptions {
filter?: Record<string, string>;
search?: string;
sort_by?: string;
sort_order?: 'asc' | 'desc';
}
export async function listPluginData(
pluginId: string,
entity: string,
page = 1,
pageSize = 20,
options?: PluginDataListOptions
) {
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: PaginatedDataResponse<PluginDataRecord>;
}>(`/plugins/${pluginId}/${entity}`, { params });
return data.data;
}
- Step 2: 扩展 getPluginSchema 返回类型
在 plugins.ts 中定义精确的 schema 类型:
export interface PluginFieldSchema {
name: string;
field_type: string;
required: boolean;
display_name: string;
ui_widget?: string;
options?: { label: string; value: string }[];
searchable?: boolean;
filterable?: boolean;
sortable?: boolean;
visible_when?: string;
unique?: boolean;
}
export interface PluginEntitySchema {
name: string;
display_name: string;
fields: PluginFieldSchema[];
}
export interface PluginSchemaResponse {
entities: PluginEntitySchema[];
}
更新 getPluginSchema 返回类型。
- Step 3: 提交
git add apps/web/src/api/pluginData.ts apps/web/src/api/plugins.ts
git commit -m "feat(web): 扩展插件 API 支持 filter/search/sort 参数和精确 schema 类型"
Task 9: 增强 PluginCRUDPage — 筛选/搜索/排序
Files:
-
Modify:
apps/web/src/pages/PluginCRUDPage.tsx -
Step 1: 在表格上方添加搜索框和筛选栏
在 PluginCRUDPage 组件中:
- 添加 state:
searchText,filters,sortBy,sortOrder - 从 schema fields 中提取
filterable字段列表,渲染Select组件 - 当
enable_search为 true 时渲染Input.Search - 修改
fetchData传递 filter/search/sort 参数
// 搜索和筛选栏(在 Table 上方)
<Space style={{ marginBottom: 16 }} wrap>
{enableSearch && (
<Input.Search
placeholder="搜索..."
allowClear
style={{ width: 240 }}
onSearch={(value) => { setSearchText(value); fetchData(1, pageSize, value, filters); }}
/>
)}
{filterableFields.map(field => (
<Select
key={field.name}
placeholder={field.display_name}
allowClear
style={{ width: 150 }}
options={field.options}
onChange={(value) => {
const newFilters = { ...filters };
if (value) newFilters[field.name] = value;
else delete newFilters[field.name];
setFilters(newFilters);
fetchData(1, pageSize, searchText, newFilters);
}}
/>
))}
</Space>
- Step 2: 添加视图切换(timeline)
当 enable_views 包含多个视图时,在右上角添加 Segmented 组件切换视图模式。Phase 1 先只实现 table 视图,timeline 留到 Phase 2。
- Step 3: 提交
git add apps/web/src/pages/PluginCRUDPage.tsx
git commit -m "feat(web): PluginCRUDPage 增加搜索框、筛选栏和排序支持"
Task 10: 实现 PluginDetailPage — Drawer + Tabs + 嵌套 CRUD
Files:
-
Create:
apps/web/src/pages/PluginDetailPage.tsx -
Step 1: 创建 PluginDetailPage 组件
import { Drawer, Tabs, Descriptions, Table, Spin } from 'antd';
import { useState, useEffect } from 'react';
import { getPluginSchema, PluginEntitySchema } from '../api/plugins';
import { listPluginData, PluginDataRecord } from '../api/pluginData';
interface PluginDetailPageProps {
open: boolean;
onClose: () => void;
pluginId: string;
entityId: string;
recordId: string;
recordData: Record<string, unknown>;
schema: PluginEntitySchema | null;
}
export function PluginDetailPage({ open, onClose, pluginId, entityId, recordId, recordData, schema }: PluginDetailPageProps) {
// 1. 获取完整的 plugin schema(包含所有 entity)
// 2. 找到 detail 页面配置的 sections
// 3. 渲染 Drawer + Tabs,每个 section 渲染不同内容
// ...
}
核心逻辑:
-
fieldssection →Descriptions组件展示字段值 -
crudsection → 嵌套的Table组件,通过filter_field自动过滤 -
Step 2: 在 PluginCRUDPage 中集成详情按钮
当 manifest 中声明了同实体的 detail 页面时,在操作列自动添加"详情"按钮:
// 在 PluginCRUDPage 的操作列中
{hasDetailPage && (
<Button type="link" size="small" onClick={() => setDetailRecord(record)}>
详情
</Button>
)}
- Step 3: 提交
git add apps/web/src/pages/PluginDetailPage.tsx apps/web/src/pages/PluginCRUDPage.tsx
git commit -m "feat(web): 新增 PluginDetailPage Drawer 详情页,支持嵌套 CRUD"
Task 11: 实现 visible_when 条件表单字段
Files:
-
Modify:
apps/web/src/pages/PluginCRUDPage.tsx:166-192(renderFormField) -
Step 1: 实现 visible_when 解析函数
function parseVisibleWhen(expression: string): { field: string; value: string } | null {
const regex = /^(\w+)\s*==\s*'([^']*)'$/;
const match = expression.trim().match(regex);
if (!match) return null;
return { field: match[1], value: match[2] };
}
- Step 2: 在表单渲染中集成条件显示
在 renderFormField 中,如果字段有 visible_when,用 Form.useWatch 监听条件字段:
// 在表单渲染的 map 中
{fields.map(field => {
const visible = field.visible_when
? shouldShowField(form.getFieldsValue(), field.visible_when)
: true;
if (!visible) return null;
return <Form.Item key={field.name} ...>{renderFormField(field)}</Form.Item>;
})}
需要在组件中添加 form.getFieldsValue() 的响应式监听。可以使用 Form.useWatch 或在 onValuesChanged 回调中触发重渲染。
- Step 3: 提交
git add apps/web/src/pages/PluginCRUDPage.tsx
git commit -m "feat(web): 表单字段支持 visible_when 条件显示"
Task 12: Phase 1 端到端验证
- Step 1: 启动 Docker 环境
Run: cd docker && docker compose up -d
- Step 2: 编译并启动后端
Run: cargo run -p erp-server
- Step 3: 启动前端
Run: cd apps/web && pnpm dev
- Step 4: 运行全量测试
Run: cargo test --workspace
-
Step 5: 手动验证验收标准
-
使用已有的测试插件验证:唯一字段重复插入返回冲突错误
-
REST API 支持
?filter={"field":"value"}&search=keyword -
PluginCRUDPage 自动渲染筛选栏和搜索框
-
visible_when 条件字段正常工作
Chunk 3: Phase 2 — CRM WASM 插件核心
Task 13: 创建 CRM 插件 crate 骨架
Files:
-
Create:
crates/erp-plugin-crm/Cargo.toml -
Create:
crates/erp-plugin-crm/src/lib.rs -
Create:
crates/erp-plugin-crm/plugin.toml -
Modify:
Cargo.toml(workspace members) -
Step 1: 创建 Cargo.toml
[package]
name = "erp-plugin-crm"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.55"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
- Step 2: 创建 src/lib.rs
参考 erp-plugin-test-sample 的结构,实现 Guest trait 的三个方法:
wit_bindgen::generate!({
path: "../erp-plugin-prototype/wit/plugin.wit",
world: "plugin-world",
});
use crate::exports::erp::plugin::plugin_api::Guest;
struct CrmPlugin;
impl Guest for CrmPlugin {
fn init() -> Result<(), String> {
Ok(())
}
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
let _ = tenant_id;
Ok(())
}
fn handle_event(event_type: String, payload: Vec<u8>) -> Result<(), String> {
let _ = (event_type, payload);
Ok(())
}
}
export!(CrmPlugin);
注意:wit_bindgen::generate! 的 path 指向 erp-plugin-prototype(非 erp-plugin),因为 WIT 文件在 prototype crate 中。export! 宏由 generate! 自动生成。完整参考 crates/erp-plugin-test-sample/src/lib.rs。
- Step 3: 创建 plugin.toml
使用设计规格 §4.1 中完整的 manifest 配置。
- Step 4: 注册到 workspace
在根 Cargo.toml 的 [workspace] members 中添加 "crates/erp-plugin-crm"。
- Step 5: 编译 WASM
Run: cargo build -p erp-plugin-crm --target wasm32-unknown-unknown --release
Expected: 编译成功,生成 .wasm 文件
- Step 6: 转换为 Component
Run: wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_crm.wasm -o target/erp_plugin_crm.component.wasm
- Step 7: 提交
git add crates/erp-plugin-crm/ Cargo.toml
git commit -m "feat(crm): 创建 CRM 插件 crate 骨架和 manifest"
Task 14: 通过 PluginAdmin 上传并测试 CRM 插件
- Step 1: 上传插件
通过 PluginAdmin 页面上传 erp_plugin_crm.component.wasm 和 plugin.toml。
- Step 2: 安装插件
验证:5 个动态表创建成功、9 个权限注册成功。
- Step 3: 启用插件
验证:WASM 初始化成功,侧边栏出现 CRM 菜单项。
-
Step 4: 基础 CRUD 测试
-
创建客户(企业/个人)
-
创建联系人(关联客户)
-
创建沟通记录(关联客户)
-
列表查询(验证 filter 按客户过滤)
-
编辑/删除
Chunk 4: Phase 2 — 前端 tree/timeline/tabs 页面类型
Task 15: 实现 PluginTabsPage
Files:
-
Create:
apps/web/src/pages/PluginTabsPage.tsx -
Step 1: 创建 tabs 容器组件
使用 Ant Design Tabs 组件,每个 tab 根据子页面类型渲染对应组件:
export function PluginTabsPage({ pluginId, tabs, schema }: TabsPageProps) {
const [activeKey, setActiveKey] = useState(tabs[0]?.label);
return (
<Tabs activeKey={activeKey} onChange={setActiveKey} items={tabs.map(tab => ({
key: tab.label,
label: tab.label,
children: renderPageContent(pluginId, tab, schema),
}))} />
);
}
- Step 2: 提交
git add apps/web/src/pages/PluginTabsPage.tsx
git commit -m "feat(web): 新增 PluginTabsPage 通用标签页容器"
Task 16: 实现 PluginTreePage
Files:
-
Create:
apps/web/src/pages/PluginTreePage.tsx -
Step 1: 创建树形页面组件
使用 Ant Design DirectoryTree,从 REST API 加载全量数据,前端构建树结构:
export function PluginTreePage({ pluginId, config, schema }: TreePageProps) {
// config: { entity, id_field, parent_field, label_field }
// 1. 加载全量数据
// 2. 构建树结构(根据 parent_field 建立父子关系)
// 3. 渲染 DirectoryTree
// 4. 点击节点显示右侧详情面板
}
- Step 2: 提交
git add apps/web/src/pages/PluginTreePage.tsx
git commit -m "feat(web): 新增 PluginTreePage 通用树形页面"
Task 17: 实现 timeline 视图模式
Files:
-
Modify:
apps/web/src/pages/PluginCRUDPage.tsx -
Step 1: 添加 timeline 视图渲染
在 PluginCRUDPage 中,当 enable_views 包含 "timeline" 时,添加 timeline 渲染逻辑:
const renderTimeline = () => (
<Timeline items={data.map(item => ({
children: (
<div>
<p><strong>{item[titleField]}</strong></p>
<p>{item[contentField]}</p>
<p style={{ color: '#999' }}>{item[dateField]}</p>
</div>
),
color: getTypeColor(item[typeField]),
}))} />
);
- Step 2: 集成 Segmented 切换
{enableViews.length > 1 && (
<Segmented
options={enableViews.map(v => ({ label: v === 'table' ? '表格' : '时间线', value: v }))}
value={viewMode}
onChange={setViewMode}
/>
)}
{viewMode === 'table' ? <Table ... /> : renderTimeline()}
- Step 3: 提交
git add apps/web/src/pages/PluginCRUDPage.tsx
git commit -m "feat(web): PluginCRUDPage 添加 timeline 视图模式切换"
Task 18: 侧边栏动态菜单集成
Files:
-
Modify:
apps/web/src/stores/plugin.ts:38-58(refreshMenuItems) -
Modify:
apps/web/src/layouts/MainLayout.tsx:172-192(插件菜单渲染) -
Modify:
apps/web/src/pages/PluginCRUDPage.tsx或App.tsx(路由) -
Step 1: 修改 plugin store 的菜单生成逻辑
从 manifest 的 ui.pages 生成菜单项(而非仅遍历 entities):
// refreshMenuItems 中
for (const plugin of plugins) {
if (plugin.status !== 'running') continue;
const schema = await getPluginSchema(plugin.id); // 或从缓存读取
for (const page of schema.ui?.pages || []) {
if (page.type === 'tabs') {
// tabs 类型聚合为一个菜单项
items.push({
key: `/plugins/${plugin.id}/${page.label}`,
label: page.label,
icon: page.icon,
pluginId: plugin.id,
pageType: 'tabs',
});
} else if (page.type !== 'detail') {
// crud/tree 各生成一个菜单项
items.push({
key: `/plugins/${plugin.id}/${page.entity}`,
label: page.label,
icon: page.icon,
pluginId: plugin.id,
entity: page.entity,
pageType: page.type,
});
}
}
}
- Step 2: 修改 MainLayout 支持动态图标
将 182 行的固定 <AppstoreOutlined /> 改为根据 item.icon 选择图标。
- Step 3: 修改路由分发
在 App.tsx 或路由配置中,根据 pageType 匹配不同的页面组件:
-
crud→PluginCRUDPage -
tabs→PluginTabsPage -
tree→PluginTreePage -
Step 4: 提交
git add apps/web/src/stores/plugin.ts apps/web/src/layouts/MainLayout.tsx apps/web/src/App.tsx
git commit -m "feat(web): 侧边栏动态菜单集成,支持 manifest pages 配置"
Task 19: Phase 2 端到端验证
- Step 1: 启动全栈环境
Run: .\dev.ps1
-
Step 2: 验收 CRM 插件
-
上传 → 安装 → 启用,侧边栏出现 CRM 菜单("客户管理"、"联系人"、"沟通记录"、"标签管理"、"客户关系")
-
客户 CRUD:创建企业客户、个人客户,验证差异化字段
-
客户详情 Drawer:点击详情按钮,看到关联联系人和沟通记录
-
客户层级树:展开/折叠正常
-
沟通记录 timeline 视图切换
-
权限控制:不同角色看到不同数据
Chunk 5: Phase 3 — 高级功能 + Skill 提炼
Task 20: 实现 graph 页面类型(AntV G6)
Files:
-
Create:
apps/web/src/pages/PluginGraphPage.tsx -
Step 1: 安装 @antv/g6
Run: cd apps/web && pnpm add @antv/g6
-
Step 2: 实现 graph 页面组件
-
Dynamic import 按需加载 G6
-
以选中客户为中心,展示 1 跳关系
-
点击节点继续扩展
-
节点标签用客户名称,边标签用关系类型
-
Step 3: 提交
git add apps/web/src/pages/PluginGraphPage.tsx apps/web/package.json apps/web/pnpm-lock.yaml
git commit -m "feat(web): 新增 PluginGraphPage 关系图谱页面(AntV G6)"
Task 21: 实现 dashboard 页面类型
Files:
-
Create:
apps/web/src/pages/PluginDashboardPage.tsx -
Step 1: 实现 dashboard 组件
使用 Ant Design Statistic / Card + @ant-design/charts 渲染统计概览。
- Step 2: 提交
git add apps/web/src/pages/PluginDashboardPage.tsx
git commit -m "feat(web): 新增 PluginDashboardPage 统计概览页面"
Task 22: Host API 扩展 — db-count / db-aggregate
Files:
-
Modify:
crates/erp-plugin/wit/plugin.wit(新增接口) -
Modify:
crates/erp-plugin/src/host.rs(实现) -
Modify:
crates/erp-plugin/src/dynamic_table.rs(SQL 构建) -
Step 1: 在 WIT 中新增 db-count 和 db-aggregate 接口
-
Step 2: 实现 Host 端方法
-
Step 3: 更新 CRM 插件 bindgen
-
Step 4: 提交
git add crates/erp-plugin/wit/ crates/erp-plugin/src/ crates/erp-plugin-crm/
git commit -m "feat(plugin): Host API 新增 db-count 和 db-aggregate"
Task 23: 提炼插件开发 Skill
Files:
-
Create:
.claude/skills/plugin-development/SKILL.md -
Step 1: 编写 skill 文档
基于 CRM 开发经验,提炼以下内容:
- 插件开发流程指南(需求 → manifest → Rust → 编译 → 上传 → 测试)
- 各页面类型的 manifest 配置模板
- Rust 插件脚手架(init/on_tenant_created/handle_event 模板)
- 可用页面类型清单(crud/detail/tree/timeline/tabs/graph/dashboard)
- 可用字段属性清单(searchable/filterable/sortable/visible_when/unique)
- 权限声明指南
- 测试检查清单
- 常见问题与陷阱
- Step 2: 提交
git add .claude/skills/plugin-development/
git commit -m "docs: 提炼 CRM 插件开发经验为可复用 skill"
Task 24: Phase 3 端到端验证 + 最终提交
- Step 1: 全量测试
Run: cargo test --workspace
-
Step 2: 手动验证关系图谱
-
打开客户关系页面
-
以某客户为中心展示关系
-
点击节点扩展
-
筛选关系类型
-
Step 3: 手动验证统计概览
-
客户数量统计
-
分类分布
-
等级分布
-
Step 4: 最终提交
git add -A
git commit -m "feat(crm): CRM 客户管理插件 Phase 3 完成 — 关系图谱 + 统计概览 + Host API 扩展"
文件结构总览
Phase 1 新增/修改文件
crates/erp-plugin/src/
dynamic_table.rs — Bug 修复 + filtered query + GIN 索引
service.rs — 权限注册/清理
manifest.rs — PluginField/PluginPage/PluginSection 扩展
data_service.rs — filter/search/sort 集成 + 数据校验
data_handler.rs — 动态权限检查 + 过滤参数
data_dto.rs — PluginDataListParams 扩展
host.rs — (Phase 1 不改,仅标记待实现)
dto.rs — (如需)
apps/web/src/
api/pluginData.ts — listPluginData 参数扩展
api/plugins.ts — schema 类型定义
pages/PluginCRUDPage.tsx — 搜索/筛选/排序/visible_when
pages/PluginDetailPage.tsx — 新增 Drawer 详情页
Phase 2 新增/修改文件
crates/erp-plugin-crm/ — 新增 crate
Cargo.toml
src/lib.rs
plugin.toml
apps/web/src/
pages/PluginTabsPage.tsx — 新增
pages/PluginTreePage.tsx — 新增
stores/plugin.ts — 菜单生成逻辑改造
layouts/MainLayout.tsx — 动态图标/分组
App.tsx — 路由分发改造
Phase 3 新增/修改文件
crates/erp-plugin/
wit/plugin.wit — 新增 db-count/db-aggregate
src/host.rs — 实现
src/dynamic_table.rs — 聚合 SQL
apps/web/src/
pages/PluginGraphPage.tsx — 新增
pages/PluginDashboardPage.tsx — 新增
.claude/skills/plugin-development/
SKILL.md — 插件开发 skill