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:
iven
2026-04-17 11:10:31 +08:00
parent 4ea9bccba6
commit 60799176ca
2 changed files with 228 additions and 0 deletions

View File

@@ -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

View File

@@ -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());
}
}