Files
erp/docs/superpowers/plans/2026-04-16-crm-plugin-plan.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

46 KiB
Raw Blame History

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 中:

  1. 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...
}
  1. 添加辅助方法 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
    )
}
  1. 无需在 data_service.rs 手动处理 23505 错误erp-core/src/error.rsFrom<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.rsErpModule 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.rsModuleRegistry 中添加:

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.rsinstall 方法中,调用 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.rsPluginField(第 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 的所有使用位置并更新:

  1. service.rsinstall() 中如果遍历了 ui.pages,更新字段访问方式(改为 match 分支)
  2. host.rs — 如果引用了 PluginPage,更新为新 enum
  3. data_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_namelabel,去掉 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_sqlsearch_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.tslistPluginData 改为:

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 组件中:

  1. 添加 statesearchText, filters, sortBy, sortOrder
  2. 从 schema fields 中提取 filterable 字段列表,渲染 Select 组件
  3. enable_search 为 true 时渲染 Input.Search
  4. 修改 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 渲染不同内容
  // ...
}

核心逻辑:

  • fields section → Descriptions 组件展示字段值

  • crud section → 嵌套的 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.wasmplugin.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.tsxApp.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 匹配不同的页面组件:

  • crudPluginCRUDPage

  • tabsPluginTabsPage

  • treePluginTreePage

  • 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 开发经验,提炼以下内容:

  1. 插件开发流程指南(需求 → manifest → Rust → 编译 → 上传 → 测试)
  2. 各页面类型的 manifest 配置模板
  3. Rust 插件脚手架init/on_tenant_created/handle_event 模板)
  4. 可用页面类型清单crud/detail/tree/timeline/tabs/graph/dashboard
  5. 可用字段属性清单searchable/filterable/sortable/visible_when/unique
  6. 权限声明指南
  7. 测试检查清单
  8. 常见问题与陷阱
  • 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