feat(crm): entity_select + kanban + 级联过滤声明
- 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 / 空值校验)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -78,6 +78,10 @@ pub struct PluginField {
|
||||
#[serde(default)]
|
||||
pub visible_when: Option<String>,
|
||||
pub ref_entity: Option<String>, // 外键引用的实体名
|
||||
pub ref_label_field: Option<String>, // entity_select 下拉显示的字段名
|
||||
pub ref_search_fields: Option<Vec<String>>, // entity_select 搜索匹配的字段列表
|
||||
pub cascade_from: Option<String>, // 级联过滤的来源字段(当前实体)
|
||||
pub cascade_filter: Option<String>, // 级联过滤的目标字段(引用实体的字段)
|
||||
pub validation: Option<FieldValidation>, // 字段校验规则
|
||||
#[serde(default)]
|
||||
pub no_cycle: Option<bool>, // 禁止循环引用
|
||||
@@ -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<String>,
|
||||
},
|
||||
#[serde(rename = "kanban")]
|
||||
Kanban {
|
||||
entity: String,
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
lane_field: String,
|
||||
#[serde(default)]
|
||||
lane_order: Vec<String>,
|
||||
card_title_field: String,
|
||||
#[serde(default)]
|
||||
card_subtitle_field: Option<String>,
|
||||
#[serde(default)]
|
||||
card_fields: Vec<String>,
|
||||
#[serde(default)]
|
||||
enable_drag: Option<bool>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 插件页面区段(用于 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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user