# 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)]` 模块中添加: ```rust #[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` 的分支: ```rust // 在 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... } ``` 2. 添加辅助方法 `build_unique_index_sql` 供测试使用: ```rust 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 ) } ``` 3. **无需在 data_service.rs 手动处理 23505 错误**。`erp-core/src/error.rs` 的 `From 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: 提交** ```bash 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 中添加: ```rust /// 返回此模块需要注册的权限列表,在 install/启用时由 ModuleRegistry 调用 fn permissions(&self) -> Vec { vec![] } /// 返回此模块需要清理的权限前缀,在 uninstall 时调用 fn permission_prefix(&self) -> Option { None } ``` 在 `erp-core` 中定义 `PermissionDescriptor`: ```rust #[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 中提取权限: ```rust fn permissions(&self) -> Vec { // 从 engine 中获取所有已安装插件的 manifest // 遍历 manifest.permissions,生成 PermissionDescriptor 列表 // module 字段设为 format!("plugin:{}", plugin_id) } ``` - [ ] **Step 3: 在 ModuleRegistry 中添加权限注册流程** 在 `crates/erp-core/src/module.rs` 的 `ModuleRegistry` 中添加: ```rust 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: 提交** ```bash 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` 测试模块中添加: ```rust #[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`,保持不变: ```rust #[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, pub display_name: Option, // 注意:现有是 Option pub ui_widget: Option, pub options: Option>, // 新增字段 — 全部 Optional + serde(default) 保证向后兼容 #[serde(default)] pub searchable: Option, #[serde(default)] pub filterable: Option, #[serde(default)] pub sortable: Option, #[serde(default)] pub visible_when: Option, } ``` - [ ] **Step 4: 替换 PluginPage 为 tagged enum PluginPageType** 直接删除 `PluginPage`(第 100-109 行),替换为: ```rust #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "type")] pub enum PluginPageType { #[serde(rename = "crud")] Crud { entity: String, label: String, icon: Option, enable_search: Option, enable_views: Option>, }, #[serde(rename = "tree")] Tree { entity: String, label: String, icon: Option, id_field: String, parent_field: String, label_field: String, }, #[serde(rename = "detail")] Detail { entity: String, label: String, sections: Vec, }, #[serde(rename = "tabs")] Tabs { label: String, icon: Option, tabs: Vec, }, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "type")] pub enum PluginSection { #[serde(rename = "fields")] Fields { label: String, fields: Vec, }, #[serde(rename = "crud")] Crud { label: String, entity: String, filter_field: Option, enable_views: Option>, }, } ``` 更新 `PluginUi`: ```rust #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginUi { pub pages: Vec, } ``` **删除旧的 `PluginPage` 结构体**(第 100-109 行)。 - [ ] **Step 5: 更新所有引用点** 搜索 `PluginPage` 的所有使用位置并更新: 1. **`service.rs`** — `install()` 中如果遍历了 `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,将旧格式: ```toml [[ui.pages]] route = "/products" entity = "product" display_name = "商品管理" icon = "ShoppingOutlined" menu_group = "进销存" ``` 改为新格式: ```toml [[ui.pages]] type = "crud" entity = "product" label = "商品管理" icon = "ShoppingOutlined" ``` 同时更新断言(`display_name` → `label`,去掉 `route`/`menu_group`)。 - [ ] **Step 7: 添加页面类型验证** 在 `parse_manifest` 中添加: ```rust 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: 提交** ```bash 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: 写失败测试** ```rust #[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` 中: ```rust #[derive(Debug, Deserialize, IntoParams)] pub struct PluginDataListParams { pub page: Option, pub page_size: Option, pub search: Option, // 新增 pub filter: Option, // JSON 格式: {"field":"value"} pub sort_by: Option, pub sort_order: Option, // "asc" or "desc" } ``` - [ ] **Step 4: 实现 build_filtered_query_sql** 在 `dynamic_table.rs` 中新增方法: ```rust pub fn build_filtered_query_sql( table_name: &str, tenant_id: Uuid, limit: u64, offset: u64, filter: Option, search: Option<(String, String)>, // (searchable_fields_csv, keyword) sort_by: Option, sort_order: Option, ) -> Result<(String, Vec), String> { let mut conditions = vec![ format!("\"tenant_id\" = ${}", 1), format!("\"deleted_at\" IS NULL"), ]; let mut param_idx = 2; let mut values: Vec = 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 = 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: 提交** ```bash 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` 方法签名从: ```rust pub async fn list(plugin_id: Uuid, entity_name: &str, tenant_id: Uuid, page: u64, page_size: u64, db: &DatabaseConnection) -> AppResult<(Vec, u64)> ``` 改为: ```rust pub async fn list( plugin_id: Uuid, entity_name: &str, tenant_id: Uuid, page: u64, page_size: u64, db: &DatabaseConnection, filter: Option, search: Option, search_fields: Option, sort_by: Option, sort_order: Option, ) -> AppResult<(Vec, u64)> ``` 在方法内部,将 `DynamicTableManager::build_query_sql` 替换为 `build_filtered_query_sql`。`search_fields` 参数来源:从 `plugin_entities` 表的 `schema_json` 字段反序列化出 entity 的 fields,筛选 `searchable == Some(true)` 的字段名,用逗号拼接成 CSV。如无可搜索字段则传 `None`。 ```rust // 在 list 方法内部获取 searchable fields let entity_fields = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?; let search_fields_csv: Option = { 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 字段: ```rust let filter: Option = 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` 从硬编码改为动态计算: ```rust // 辅助函数:计算插件数据操作所需的权限码 // 格式:{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 中的权限检查: ```rust // 原来: 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: 提交** ```bash 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 索引: ```rust // 为 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: 提交** ```bash 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 的字段定义: ```rust 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> { 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 辅助函数** ```rust 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`,使用 `as_deref().unwrap_or()` 提供回退值。 - [ ] **Step 3: 在 create/update 中调用验证** 在 `create` 方法中(在 `build_insert_sql` 之前): ```rust 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: 提交** ```bash 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` 改为: ```typescript export interface PluginDataListOptions { filter?: Record; 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 = { 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; }>(`/plugins/${pluginId}/${entity}`, { params }); return data.data; } ``` - [ ] **Step 2: 扩展 getPluginSchema 返回类型** 在 `plugins.ts` 中定义精确的 schema 类型: ```typescript 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: 提交** ```bash 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. 添加 state:`searchText`, `filters`, `sortBy`, `sortOrder` 2. 从 schema fields 中提取 `filterable` 字段列表,渲染 `Select` 组件 3. 当 `enable_search` 为 true 时渲染 `Input.Search` 4. 修改 `fetchData` 传递 filter/search/sort 参数 ```typescript // 搜索和筛选栏(在 Table 上方) {enableSearch && ( { setSearchText(value); fetchData(1, pageSize, value, filters); }} /> )} {filterableFields.map(field => (