From 60799176ca0ebb39f68a91e1fd795f3d0356f4b7 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 17 Apr 2026 11:10:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(crm):=20entity=5Fselect=20+=20kanban=20+?= =?UTF-8?q?=20=E7=BA=A7=E8=81=94=E8=BF=87=E6=BB=A4=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PluginField 新增 ref_label_field / ref_search_fields / cascade_from / cascade_filter 字段 - PluginPageType 新增 Kanban 变体(lane_field / lane_order / card_title_field / card_subtitle_field / card_fields / enable_drag) - CRM plugin.toml: contact.customer_id 和 communication.contact_id 添加 entity_select 声明 - CRM plugin.toml: communication.contact_id 添加 cascade_from/cascade_filter 级联过滤 - CRM plugin.toml: 新增销售漏斗 kanban 页面声明 - 新增 5 个解析测试(entity_select / cascade / kanban / 空值校验) --- crates/erp-plugin-crm/plugin.toml | 20 +++ crates/erp-plugin/src/manifest.rs | 208 ++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) diff --git a/crates/erp-plugin-crm/plugin.toml b/crates/erp-plugin-crm/plugin.toml index e5813d5..e1defa6 100644 --- a/crates/erp-plugin-crm/plugin.toml +++ b/crates/erp-plugin-crm/plugin.toml @@ -181,6 +181,9 @@ display_name = "联系人" field_type = "uuid" required = true display_name = "所属客户" + ui_widget = "entity_select" + ref_label_field = "name" + ref_search_fields = ["name", "code"] [[schema.entities.fields]] name = "name" @@ -238,6 +241,11 @@ display_name = "沟通记录" name = "contact_id" field_type = "uuid" display_name = "关联联系人" + ui_widget = "entity_select" + ref_label_field = "name" + ref_search_fields = ["name"] + cascade_from = "customer_id" + cascade_filter = "customer_id" [[schema.entities.fields]] name = "type" @@ -432,3 +440,15 @@ node_label_field = "name" type = "dashboard" label = "统计概览" icon = "DashboardOutlined" + +[[ui.pages]] +type = "kanban" +entity = "customer" +label = "销售漏斗" +icon = "swap" +lane_field = "level" +lane_order = ["potential", "normal", "vip", "svip"] +card_title_field = "name" +card_subtitle_field = "code" +card_fields = ["region", "status"] +enable_drag = true diff --git a/crates/erp-plugin/src/manifest.rs b/crates/erp-plugin/src/manifest.rs index 740eb2b..67c157a 100644 --- a/crates/erp-plugin/src/manifest.rs +++ b/crates/erp-plugin/src/manifest.rs @@ -78,6 +78,10 @@ pub struct PluginField { #[serde(default)] pub visible_when: Option, pub ref_entity: Option, // 外键引用的实体名 + pub ref_label_field: Option, // entity_select 下拉显示的字段名 + pub ref_search_fields: Option>, // entity_select 搜索匹配的字段列表 + pub cascade_from: Option, // 级联过滤的来源字段(当前实体) + pub cascade_filter: Option, // 级联过滤的目标字段(引用实体的字段) pub validation: Option, // 字段校验规则 #[serde(default)] pub no_cycle: Option, // 禁止循环引用 @@ -147,6 +151,10 @@ impl PluginField { sortable: None, visible_when: None, ref_entity: None, + ref_label_field: None, + ref_search_fields: None, + cascade_from: None, + cascade_filter: None, validation: None, no_cycle: None, scope_role: None, @@ -248,6 +256,23 @@ pub enum PluginPageType { #[serde(default)] icon: Option, }, + #[serde(rename = "kanban")] + Kanban { + entity: String, + label: String, + #[serde(default)] + icon: Option, + lane_field: String, + #[serde(default)] + lane_order: Vec, + card_title_field: String, + #[serde(default)] + card_subtitle_field: Option, + #[serde(default)] + card_fields: Vec, + #[serde(default)] + enable_drag: Option, + }, } /// 插件页面区段(用于 detail 页面类型) @@ -392,6 +417,28 @@ fn validate_pages(pages: &[PluginPageType]) -> PluginResult<()> { PluginPageType::Dashboard { .. } => { // dashboard 无需额外验证 } + PluginPageType::Kanban { + entity, + lane_field, + card_title_field, + .. + } => { + if entity.is_empty() { + return Err(PluginError::InvalidManifest( + "kanban page 的 entity 不能为空".into(), + )); + } + if lane_field.is_empty() { + return Err(PluginError::InvalidManifest( + "kanban page 的 lane_field 不能为空".into(), + )); + } + if card_title_field.is_empty() { + return Err(PluginError::InvalidManifest( + "kanban page 的 card_title_field 不能为空".into(), + )); + } + } } } Ok(()) @@ -797,4 +844,165 @@ data_scope_levels = ["self", "department", "department_tree", "all"] let perm = &manifest.permissions.unwrap()[0]; assert_eq!(perm.data_scope_levels.as_ref().unwrap().len(), 4); } + + #[test] + fn parse_field_with_entity_select() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "contact" +display_name = "联系人" + +[[schema.entities.fields]] +name = "customer_id" +field_type = "uuid" +required = true +display_name = "所属客户" +ui_widget = "entity_select" +ref_label_field = "name" +ref_search_fields = ["name", "code"] +"#; + let manifest = parse_manifest(toml).unwrap(); + let field = &manifest.schema.unwrap().entities[0].fields[0]; + assert_eq!(field.ui_widget.as_deref(), Some("entity_select")); + assert_eq!(field.ref_label_field.as_deref(), Some("name")); + assert_eq!( + field.ref_search_fields.as_deref(), + Some( + &["name".to_string(), "code".to_string()][..] + ) + ); + } + + #[test] + fn parse_field_with_cascade() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "communication" +display_name = "沟通记录" + +[[schema.entities.fields]] +name = "customer_id" +field_type = "uuid" +required = true +display_name = "关联客户" + +[[schema.entities.fields]] +name = "contact_id" +field_type = "uuid" +display_name = "关联联系人" +ui_widget = "entity_select" +ref_label_field = "name" +ref_search_fields = ["name"] +cascade_from = "customer_id" +cascade_filter = "customer_id" +"#; + let manifest = parse_manifest(toml).unwrap(); + let fields = &manifest.schema.unwrap().entities[0].fields; + let contact_field = &fields[1]; + assert_eq!(contact_field.ui_widget.as_deref(), Some("entity_select")); + assert_eq!(contact_field.cascade_from.as_deref(), Some("customer_id")); + assert_eq!(contact_field.cascade_filter.as_deref(), Some("customer_id")); + } + + #[test] + fn parse_kanban_page() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[ui] +[[ui.pages]] +type = "kanban" +entity = "customer" +label = "销售漏斗" +icon = "swap" +lane_field = "level" +lane_order = ["potential", "normal", "vip", "svip"] +card_title_field = "name" +card_subtitle_field = "code" +card_fields = ["region", "status"] +enable_drag = true +"#; + let manifest = parse_manifest(toml).unwrap(); + let ui = manifest.ui.unwrap(); + assert_eq!(ui.pages.len(), 1); + match &ui.pages[0] { + PluginPageType::Kanban { + entity, + label, + icon, + lane_field, + lane_order, + card_title_field, + card_subtitle_field, + card_fields, + enable_drag, + } => { + assert_eq!(entity, "customer"); + assert_eq!(label, "销售漏斗"); + assert_eq!(icon.as_deref(), Some("swap")); + assert_eq!(lane_field, "level"); + assert_eq!(lane_order, &["potential", "normal", "vip", "svip"]); + assert_eq!(card_title_field, "name"); + assert_eq!(card_subtitle_field.as_deref(), Some("code")); + assert_eq!(card_fields, &["region", "status"]); + assert_eq!(*enable_drag, Some(true)); + } + _ => panic!("Expected Kanban page type"), + } + } + + #[test] + fn reject_empty_entity_in_kanban_page() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[ui] +[[ui.pages]] +type = "kanban" +entity = "" +label = "测试" +lane_field = "status" +card_title_field = "name" +"#; + let result = parse_manifest(toml); + assert!(result.is_err()); + } + + #[test] + fn reject_empty_lane_field_in_kanban_page() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[ui] +[[ui.pages]] +type = "kanban" +entity = "customer" +label = "测试" +lane_field = "" +card_title_field = "name" +"#; + let result = parse_manifest(toml); + assert!(result.is_err()); + } }