Files
hms/crates/erp-plugin/src/manifest.rs
iven 40b37cc776 feat(plugin,freelance,itops,web): P5-P6 dashboard widgets 平台扩展 + 仪表盘声明
P5 平台扩展:
- manifest.rs: Dashboard 变体新增 widgets 字段
- manifest.rs: 定义 PluginWidget/StatCard/ActionQuery 类型
- 前端: 扩展 DashboardWidget 类型支持 stat_cards/action_list/funnel/card_list
- 前端: 新增 4 个 widget 渲染器 (StatCardsWidget/ActionListWidget/FunnelStageWidget/CardListWidget)
- 前端: PluginDashboardPage widget 数据加载支持新类型

P6 仪表盘 widgets:
- freelance: 工作台仪表盘 4 个 widgets (财务概览/紧急待办/商机漏斗/活跃项目)
- itops: 新增运维概览仪表盘 2 个 widgets (运维概览/紧急待办)
2026-04-20 09:35:27 +08:00

1782 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde::{Deserialize, Serialize};
use crate::error::{PluginError, PluginResult};
/// 插件清单 — 从 TOML 文件解析
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub metadata: PluginMetadata,
pub schema: Option<PluginSchema>,
pub events: Option<PluginEvents>,
pub ui: Option<PluginUi>,
pub permissions: Option<Vec<PluginPermission>>,
/// 插件配置项声明 — 平台自动生成配置页面
#[serde(default)]
pub settings: Option<PluginSettings>,
/// 编号规则声明 — 绑定实体字段到自动编号
#[serde(default)]
pub numbering: Option<Vec<PluginNumbering>>,
/// 打印模板声明
#[serde(default)]
pub templates: Option<Vec<PluginTemplate>>,
/// 触发事件声明 — 数据 CRUD 时自动发布域事件
#[serde(default)]
pub trigger_events: Option<Vec<PluginTriggerEvent>>,
}
/// 插件元数据
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginMetadata {
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub author: String,
#[serde(default)]
pub min_platform_version: Option<String>,
#[serde(default)]
pub dependencies: Vec<String>,
}
/// 插件 Schema — 定义动态实体
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginSchema {
pub entities: Vec<PluginEntity>,
}
/// 插件实体定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginEntity {
pub name: String,
pub display_name: String,
#[serde(default)]
pub fields: Vec<PluginField>,
#[serde(default)]
pub indexes: Vec<PluginIndex>,
#[serde(default)]
pub relations: Vec<PluginRelation>,
#[serde(default)]
pub data_scope: Option<bool>, // 是否启用行级数据权限
#[serde(default)]
pub is_public: Option<bool>, // 是否可被其他插件引用
#[serde(default)]
pub importable: Option<bool>, // 是否支持数据导入
#[serde(default)]
pub exportable: Option<bool>, // 是否支持数据导出
}
/// 字段校验规则
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldValidation {
pub pattern: Option<String>, // 正则表达式
pub message: 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>,
pub ui_widget: Option<String>,
pub options: Option<Vec<serde_json::Value>>,
#[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>,
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>, // 禁止循环引用
#[serde(default)]
pub scope_role: Option<String>, // 标记为数据权限的"所有者"字段
pub ref_plugin: Option<String>, // 跨插件引用的目标插件 manifest ID如 "erp-crm"
pub ref_fallback_label: Option<String>, // 目标插件未安装时的降级显示文本
}
/// 字段类型
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginFieldType {
String,
Integer,
Float,
Boolean,
Date,
DateTime,
Json,
Uuid,
Decimal,
}
impl PluginFieldType {
/// Generated Column 的 SQL 类型
pub fn generated_sql_type(&self) -> &'static str {
match self {
Self::String | Self::Json => "TEXT",
Self::Integer => "INTEGER",
Self::Float => "DOUBLE PRECISION",
Self::Decimal => "NUMERIC",
Self::Boolean => "BOOLEAN",
Self::Date => "DATE",
// TIMESTAMPTZ cast 不是 immutablegenerated column 不支持类型转换,存为 TEXT
Self::DateTime => "TEXT",
Self::Uuid => "UUID",
}
}
/// Generated Column 的表达式
pub fn generated_expr(&self, field_name: &str) -> String {
match self {
Self::String | Self::Json | Self::DateTime => format!("data->>'{}'", field_name),
_ => format!("(data->>'{}')::{}", field_name, self.generated_sql_type()),
}
}
/// 该类型是否适合生成 Generated Column
pub fn supports_generated_column(&self) -> bool {
!matches!(self, Self::Json)
}
}
impl PluginField {
/// 测试辅助:构造一个全默认值的 PluginField
#[cfg(test)]
pub fn default_for_field() -> Self {
Self {
name: String::new(),
field_type: PluginFieldType::String,
required: false,
unique: false,
default: None,
display_name: None,
ui_widget: None,
options: None,
searchable: None,
filterable: None,
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,
ref_plugin: None,
ref_fallback_label: None,
}
}
}
/// 索引定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginIndex {
pub name: String,
pub fields: Vec<String>,
#[serde(default)]
pub unique: bool,
}
/// 级联删除策略
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum OnDeleteStrategy {
Nullify, // 置空外键字段
Cascade, // 级联软删除
Restrict, // 存在关联时拒绝删除
}
/// 实体关联关系声明
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginRelation {
pub entity: String,
pub foreign_key: String,
pub on_delete: OnDeleteStrategy,
#[serde(default)]
pub name: Option<String>, // 关联名称UI 显示用)
#[serde(default, alias = "type")]
pub relation_type: Option<String>, // "one_to_many" | "many_to_one" | "many_to_many"
#[serde(default)]
pub display_field: Option<String>, // 关联记录的显示字段
}
/// 事件订阅配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginEvents {
pub subscribe: Vec<String>,
}
/// UI 页面配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginUi {
pub pages: Vec<PluginPageType>,
}
/// 插件页面类型tagged enumTOML 中通过 type 字段区分)
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum PluginPageType {
#[serde(rename = "crud")]
Crud {
entity: String,
label: String,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
enable_search: Option<bool>,
#[serde(default)]
enable_views: Option<Vec<String>>,
},
#[serde(rename = "tree")]
Tree {
entity: String,
label: String,
#[serde(default)]
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,
#[serde(default)]
icon: Option<String>,
tabs: Vec<PluginPageType>,
},
#[serde(rename = "graph")]
Graph {
entity: String,
label: String,
#[serde(default)]
icon: Option<String>,
relationship_entity: String,
source_field: String,
target_field: String,
edge_label_field: String,
node_label_field: String,
},
#[serde(rename = "dashboard")]
Dashboard {
label: String,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
widgets: Vec<PluginWidget>,
},
#[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>,
},
}
/// Dashboard Widget 类型
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PluginWidget {
#[serde(rename = "stat_cards")]
StatCards {
label: String,
cards: Vec<StatCard>,
},
#[serde(rename = "action_list")]
ActionList {
label: String,
#[serde(default)]
max_items: Option<u32>,
queries: Vec<ActionQuery>,
},
#[serde(rename = "funnel")]
Funnel {
label: String,
entity: String,
lane_field: String,
#[serde(default)]
value_field: Option<String>,
lane_order: Vec<String>,
},
#[serde(rename = "card_list")]
CardList {
label: String,
entity: String,
#[serde(default)]
filter: Option<String>,
#[serde(default)]
max_items: Option<u32>,
title_field: String,
#[serde(default)]
subtitle_field: Option<String>,
#[serde(default)]
tags: Vec<String>,
},
}
/// 统计卡片
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct StatCard {
pub entity: String,
#[serde(default)]
pub aggregate: Option<String>,
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub filter: Option<String>,
pub label: String,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub color: Option<String>,
}
/// 待办行动查询
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ActionQuery {
pub entity: String,
#[serde(default)]
pub filter: Option<String>,
#[serde(default)]
pub sort: Option<String>,
pub label_field: String,
#[serde(default)]
pub subtitle_field: Option<String>,
pub action: String,
#[serde(default)]
pub icon: Option<String>,
}
/// 插件页面区段(用于 detail 页面类型)
#[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,
#[serde(default)]
filter_field: Option<String>,
#[serde(default)]
enable_views: Option<Vec<String>>,
},
}
/// 权限定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginPermission {
pub code: String,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub data_scope_levels: Option<Vec<String>>, // 支持的数据范围等级
}
// ============================================================
// P2 平台通用服务 — manifest 扩展
// ============================================================
/// 插件配置项声明 — 平台根据此声明自动生成配置页面
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginSettings {
pub fields: Vec<PluginSettingField>,
}
/// 单个配置字段
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginSettingField {
pub name: String,
pub display_name: String,
#[serde(default)]
pub field_type: PluginSettingType,
#[serde(default)]
pub default_value: Option<serde_json::Value>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub description: Option<String>,
/// select/multiselect 类型的选项列表
#[serde(default)]
pub options: Option<Vec<serde_json::Value>>,
/// 数值范围 [min, max]
#[serde(default)]
pub range: Option<(f64, f64)>,
/// 分组名称 — 同组的字段在 UI 上放在一起
#[serde(default)]
pub group: Option<String>,
}
/// 配置字段类型
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PluginSettingType {
#[default]
Text,
Number,
Boolean,
Select,
Multiselect,
Color,
Date,
Datetime,
Json,
}
/// 编号规则声明 — 绑定实体字段到自动编号
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginNumbering {
pub entity: String,
pub field: String,
#[serde(default)]
pub prefix: String,
#[serde(default = "default_numbering_format")]
pub format: String,
#[serde(default)]
pub reset_rule: PluginNumberingReset,
#[serde(default = "default_seq_length")]
pub seq_length: u32,
#[serde(default)]
pub separator: Option<String>,
}
fn default_numbering_format() -> String {
"{PREFIX}-{YEAR}-{SEQ:4}".to_string()
}
fn default_seq_length() -> u32 {
4
}
/// 编号重置周期
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PluginNumberingReset {
#[default]
Never,
Daily,
Monthly,
Yearly,
}
/// 打印模板声明
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginTemplate {
pub name: String,
pub display_name: String,
pub entity: String,
#[serde(default = "default_template_format")]
pub format: String,
/// 模板文件路径(相对于插件根目录)
#[serde(default)]
pub template_file: Option<String>,
/// 内联 HTML 模板(与 template_file 二选一)
#[serde(default)]
pub template_html: Option<String>,
}
fn default_template_format() -> String {
"pdf".to_string()
}
/// 触发事件声明 — 数据 CRUD 操作时自动发布域事件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginTriggerEvent {
pub name: String,
pub display_name: String,
#[serde(default)]
pub description: String,
pub entity: String,
pub on: PluginTriggerOn,
}
/// 触发时机
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginTriggerOn {
Create,
Update,
Delete,
CreateOrUpdate,
}
/// 从 TOML 字符串解析插件清单
pub fn parse_manifest(toml_str: &str) -> PluginResult<PluginManifest> {
let manifest: PluginManifest =
toml::from_str(toml_str).map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
// 验证必填字段
if manifest.metadata.id.is_empty() {
return Err(PluginError::InvalidManifest("metadata.id 不能为空".to_string()));
}
if manifest.metadata.name.is_empty() {
return Err(PluginError::InvalidManifest(
"metadata.name 不能为空".to_string(),
));
}
// 验证实体名称
if let Some(schema) = &manifest.schema {
for entity in &schema.entities {
if entity.name.is_empty() {
return Err(PluginError::InvalidManifest(
"entity.name 不能为空".to_string(),
));
}
// 验证实体名称只包含合法字符
if !entity
.name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return Err(PluginError::InvalidManifest(format!(
"entity.name '{}' 只能包含字母、数字和下划线",
entity.name
)));
}
}
}
// 验证页面类型配置
if let Some(ui) = &manifest.ui {
validate_pages(&ui.pages)?;
}
// 验证编号规则引用的实体存在
if let Some(numbering) = &manifest.numbering {
let entity_names: Vec<&str> = manifest
.schema
.as_ref()
.map(|s| s.entities.iter().map(|e| e.name.as_str()).collect())
.unwrap_or_default();
for rule in numbering {
if !entity_names.contains(&rule.entity.as_str()) {
return Err(PluginError::InvalidManifest(format!(
"numbering 引用了不存在的 entity '{}'",
rule.entity
)));
}
}
}
// 验证触发事件引用的实体存在
if let Some(triggers) = &manifest.trigger_events {
let entity_names: Vec<&str> = manifest
.schema
.as_ref()
.map(|s| s.entities.iter().map(|e| e.name.as_str()).collect())
.unwrap_or_default();
for trigger in triggers {
if !entity_names.contains(&trigger.entity.as_str()) {
return Err(PluginError::InvalidManifest(format!(
"trigger_events 引用了不存在的 entity '{}'",
trigger.entity
)));
}
}
}
Ok(manifest)
}
/// 递归验证页面配置
fn validate_pages(pages: &[PluginPageType]) -> PluginResult<()> {
for page in pages {
match page {
PluginPageType::Crud { entity, .. } => {
if entity.is_empty() {
return Err(PluginError::InvalidManifest(
"crud page 的 entity 不能为空".into(),
));
}
}
PluginPageType::Tree {
entity: _,
label: _,
icon: _,
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_field/parent_field/label_field 不能为空".into(),
));
}
}
PluginPageType::Detail { entity, sections, .. } => {
if entity.is_empty() {
return Err(PluginError::InvalidManifest(
"detail page 的 entity 不能为空".into(),
));
}
if sections.is_empty() {
return Err(PluginError::InvalidManifest(
"detail page 的 sections 不能为空".into(),
));
}
}
PluginPageType::Tabs { tabs, .. } => {
if tabs.is_empty() {
return Err(PluginError::InvalidManifest(
"tabs page 的 tabs 不能为空".into(),
));
}
validate_pages(tabs)?;
}
PluginPageType::Graph {
entity,
relationship_entity,
source_field,
target_field,
..
} => {
if entity.is_empty() || relationship_entity.is_empty() {
return Err(PluginError::InvalidManifest(
"graph page 的 entity/relationship_entity 不能为空".into(),
));
}
if source_field.is_empty() || target_field.is_empty() {
return Err(PluginError::InvalidManifest(
"graph page 的 source_field/target_field 不能为空".into(),
));
}
}
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(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_manifest() {
let toml = r#"
[metadata]
id = "test-plugin"
name = "测试插件"
version = "0.1.0"
"#;
let manifest = parse_manifest(toml).unwrap();
assert_eq!(manifest.metadata.id, "test-plugin");
assert_eq!(manifest.metadata.name, "测试插件");
assert!(manifest.schema.is_none());
}
#[test]
fn parse_full_manifest() {
let toml = r#"
[metadata]
id = "inventory"
name = "进销存"
version = "1.0.0"
description = "简单进销存管理"
author = "ERP Team"
[schema]
[[schema.entities]]
name = "product"
display_name = "商品"
[[schema.entities.fields]]
name = "sku"
field_type = "string"
required = true
unique = true
display_name = "SKU 编码"
[[schema.entities.fields]]
name = "price"
field_type = "decimal"
required = true
display_name = "价格"
[events]
subscribe = ["workflow.task.completed", "order.*"]
[ui]
[[ui.pages]]
type = "crud"
entity = "product"
label = "商品管理"
icon = "ShoppingOutlined"
[[permissions]]
code = "product.list"
name = "查看商品"
description = "查看商品列表"
"#;
let manifest = parse_manifest(toml).unwrap();
assert_eq!(manifest.metadata.id, "inventory");
let schema = manifest.schema.unwrap();
assert_eq!(schema.entities.len(), 1);
assert_eq!(schema.entities[0].name, "product");
assert_eq!(schema.entities[0].fields.len(), 2);
let events = manifest.events.unwrap();
assert_eq!(events.subscribe.len(), 2);
let ui = manifest.ui.unwrap();
assert_eq!(ui.pages.len(), 1);
// 验证新格式解析正确
match &ui.pages[0] {
PluginPageType::Crud { entity, label, .. } => {
assert_eq!(entity, "product");
assert_eq!(label, "商品管理");
}
_ => panic!("Expected Crud page type"),
}
}
#[test]
fn reject_empty_id() {
let toml = r#"
[metadata]
id = ""
name = "测试"
version = "0.1.0"
"#;
let result = parse_manifest(toml);
assert!(result.is_err());
}
#[test]
fn reject_invalid_entity_name() {
let toml = r#"
[metadata]
id = "test"
name = "测试"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "my-table"
display_name = "表格"
"#;
let result = parse_manifest(toml);
assert!(result.is_err());
}
#[test]
fn parse_manifest_with_new_fields_and_page_types() {
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"),
}
}
#[test]
fn reject_empty_entity_in_crud_page() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[ui]
[[ui.pages]]
type = "crud"
entity = ""
label = "测试"
"#;
let result = parse_manifest(toml);
assert!(result.is_err());
}
#[test]
fn reject_empty_tabs_in_tabs_page() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[ui]
[[ui.pages]]
type = "tabs"
label = "空标签页"
"#;
let result = parse_manifest(toml);
assert!(result.is_err());
}
#[test]
fn field_type_to_sql_mapping() {
assert_eq!(PluginFieldType::String.generated_sql_type(), "TEXT");
assert_eq!(PluginFieldType::Integer.generated_sql_type(), "INTEGER");
assert_eq!(PluginFieldType::Float.generated_sql_type(), "DOUBLE PRECISION");
assert_eq!(PluginFieldType::Decimal.generated_sql_type(), "NUMERIC");
assert_eq!(PluginFieldType::Boolean.generated_sql_type(), "BOOLEAN");
assert_eq!(PluginFieldType::Date.generated_sql_type(), "DATE");
assert_eq!(PluginFieldType::DateTime.generated_sql_type(), "TEXT");
assert_eq!(PluginFieldType::Uuid.generated_sql_type(), "UUID");
assert_eq!(PluginFieldType::Json.generated_sql_type(), "TEXT");
}
#[test]
fn field_type_generated_expression() {
assert_eq!(PluginFieldType::String.generated_expr("name"), "data->>'name'");
assert_eq!(PluginFieldType::Integer.generated_expr("age"), "(data->>'age')::INTEGER");
assert_eq!(PluginFieldType::Uuid.generated_expr("ref_id"), "(data->>'ref_id')::UUID");
}
#[test]
fn parse_field_with_ref_entity() {
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 = "所属客户"
ref_entity = "customer"
"#;
let manifest = parse_manifest(toml).unwrap();
let field = &manifest.schema.unwrap().entities[0].fields[0];
assert_eq!(field.ref_entity.as_deref(), Some("customer"));
}
#[test]
fn parse_field_with_validation() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "contact"
display_name = "联系人"
[[schema.entities.fields]]
name = "phone"
field_type = "string"
display_name = "手机号"
validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" }
"#;
let manifest = parse_manifest(toml).unwrap();
let field = &manifest.schema.unwrap().entities[0].fields[0];
let v = field.validation.as_ref().unwrap();
assert_eq!(v.pattern.as_deref(), Some("^1[3-9]\\d{9}$"));
assert_eq!(v.message.as_deref(), Some("手机号格式不正确"));
}
#[test]
fn parse_field_with_no_cycle() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "customer"
display_name = "客户"
[[schema.entities.fields]]
name = "parent_id"
field_type = "uuid"
display_name = "上级客户"
ref_entity = "customer"
no_cycle = true
"#;
let manifest = parse_manifest(toml).unwrap();
let field = &manifest.schema.unwrap().entities[0].fields[0];
assert_eq!(field.no_cycle, Some(true));
assert_eq!(field.ref_entity.as_deref(), Some("customer"));
}
#[test]
fn parse_entity_with_relations() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "customer"
display_name = "客户"
[[schema.entities.fields]]
name = "code"
field_type = "string"
required = true
display_name = "编码"
[[schema.entities.relations]]
entity = "contact"
foreign_key = "customer_id"
on_delete = "cascade"
[[schema.entities.relations]]
entity = "customer_tag"
foreign_key = "customer_id"
on_delete = "cascade"
"#;
let manifest = parse_manifest(toml).unwrap();
let entity = &manifest.schema.unwrap().entities[0];
assert_eq!(entity.relations.len(), 2);
assert_eq!(entity.relations[0].entity, "contact");
assert_eq!(entity.relations[0].foreign_key, "customer_id");
assert!(matches!(entity.relations[0].on_delete, OnDeleteStrategy::Cascade));
}
#[test]
fn parse_entity_with_data_scope() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "customer"
display_name = "客户"
data_scope = true
[[schema.entities.fields]]
name = "owner_id"
field_type = "uuid"
display_name = "负责人"
scope_role = "owner"
"#;
let manifest = parse_manifest(toml).unwrap();
let entity = &manifest.schema.unwrap().entities[0];
assert_eq!(entity.data_scope, Some(true));
assert_eq!(entity.fields[0].scope_role.as_deref(), Some("owner"));
}
#[test]
fn parse_permission_with_data_scope_levels() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[[permissions]]
code = "customer.list"
name = "查看客户"
data_scope_levels = ["self", "department", "department_tree", "all"]
"#;
let manifest = parse_manifest(toml).unwrap();
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_field_with_cross_plugin_ref() {
let toml = r#"
[metadata]
id = "erp-inventory"
name = "进销存"
version = "0.2.0"
dependencies = ["erp-crm"]
[schema]
[[schema.entities]]
name = "sales_order"
display_name = "销售订单"
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
display_name = "客户"
ui_widget = "entity_select"
ref_plugin = "erp-crm"
ref_entity = "customer"
ref_label_field = "name"
ref_search_fields = ["name", "code"]
ref_fallback_label = "CRM 客户"
"#;
let manifest = parse_manifest(toml).unwrap();
let field = &manifest.schema.unwrap().entities[0].fields[0];
assert_eq!(field.ref_plugin.as_deref(), Some("erp-crm"));
assert_eq!(field.ref_entity.as_deref(), Some("customer"));
assert_eq!(field.ref_label_field.as_deref(), Some("name"));
assert_eq!(field.ref_fallback_label.as_deref(), Some("CRM 客户"));
assert_eq!(manifest.metadata.dependencies, vec!["erp-crm"]);
}
#[test]
fn parse_entity_with_is_public() {
let toml = r#"
[metadata]
id = "erp-crm"
name = "CRM"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "customer"
display_name = "客户"
is_public = true
[[schema.entities]]
name = "internal_config"
display_name = "内部配置"
"#;
let manifest = parse_manifest(toml).unwrap();
let entities = &manifest.schema.unwrap().entities;
assert_eq!(entities[0].is_public, Some(true));
assert_eq!(entities[1].is_public, None);
}
#[test]
fn parse_relation_with_name_and_type() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "customer"
display_name = "客户"
[[schema.entities.fields]]
name = "code"
field_type = "string"
display_name = "编码"
[[schema.entities.relations]]
entity = "contact"
foreign_key = "customer_id"
on_delete = "cascade"
name = "contacts"
type = "one_to_many"
display_field = "name"
"#;
let manifest = parse_manifest(toml).unwrap();
let relation = &manifest.schema.unwrap().entities[0].relations[0];
assert_eq!(relation.entity, "contact");
assert_eq!(relation.name.as_deref(), Some("contacts"));
assert_eq!(relation.relation_type.as_deref(), Some("one_to_many"));
assert_eq!(relation.display_field.as_deref(), Some("name"));
}
#[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());
}
// ============================================================
// P2 manifest 扩展测试
// ============================================================
#[test]
fn parse_settings_section() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[settings]
[[settings.fields]]
name = "default_tax_rate"
display_name = "默认税率"
field_type = "number"
default_value = 0.13
range = [0.0, 1.0]
group = "财务"
[[settings.fields]]
name = "invoice_prefix"
display_name = "发票前缀"
field_type = "text"
default_value = "INV"
[[settings.fields]]
name = "auto_notify"
display_name = "自动通知"
field_type = "boolean"
default_value = true
description = "发票创建后是否自动发送通知"
"#;
let manifest = parse_manifest(toml).unwrap();
let settings = manifest.settings.unwrap();
assert_eq!(settings.fields.len(), 3);
assert_eq!(settings.fields[0].name, "default_tax_rate");
assert_eq!(settings.fields[0].range, Some((0.0, 1.0)));
assert_eq!(settings.fields[0].group.as_deref(), Some("财务"));
assert_eq!(settings.fields[1].name, "invoice_prefix");
assert_eq!(settings.fields[2].name, "auto_notify");
assert!(matches!(settings.fields[2].field_type, PluginSettingType::Boolean));
}
#[test]
fn parse_numbering_section() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "invoice"
display_name = "发票"
[[numbering]]
entity = "invoice"
field = "invoice_no"
prefix = "INV"
format = "{PREFIX}-{YEAR}-{SEQ:4}"
reset_rule = "yearly"
seq_length = 4
"#;
let manifest = parse_manifest(toml).unwrap();
let numbering = manifest.numbering.unwrap();
assert_eq!(numbering.len(), 1);
assert_eq!(numbering[0].entity, "invoice");
assert_eq!(numbering[0].field, "invoice_no");
assert_eq!(numbering[0].prefix, "INV");
assert!(matches!(numbering[0].reset_rule, PluginNumberingReset::Yearly));
}
#[test]
fn reject_numbering_with_unknown_entity() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[[numbering]]
entity = "nonexistent"
field = "code"
prefix = "T"
"#;
let result = parse_manifest(toml);
assert!(result.is_err());
}
#[test]
fn parse_trigger_events_section() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "invoice"
display_name = "发票"
[[trigger_events]]
name = "invoice.created"
display_name = "发票创建"
description = "新发票创建时触发"
entity = "invoice"
on = "create"
[[trigger_events]]
name = "invoice.overdue"
display_name = "发票逾期"
description = "发票超过付款期限未收款"
entity = "invoice"
on = "update"
"#;
let manifest = parse_manifest(toml).unwrap();
let triggers = manifest.trigger_events.unwrap();
assert_eq!(triggers.len(), 2);
assert_eq!(triggers[0].name, "invoice.created");
assert!(matches!(triggers[0].on, PluginTriggerOn::Create));
assert_eq!(triggers[1].name, "invoice.overdue");
}
#[test]
fn reject_trigger_event_with_unknown_entity() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[[trigger_events]]
name = "test.trigger"
display_name = "测试"
entity = "nonexistent"
on = "create"
"#;
let result = parse_manifest(toml);
assert!(result.is_err());
}
#[test]
fn parse_entity_with_import_export() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "product"
display_name = "商品"
importable = true
exportable = true
[[schema.entities]]
name = "internal_log"
display_name = "内部日志"
"#;
let manifest = parse_manifest(toml).unwrap();
let entities = &manifest.schema.unwrap().entities;
assert_eq!(entities[0].importable, Some(true));
assert_eq!(entities[0].exportable, Some(true));
assert_eq!(entities[1].importable, None);
}
#[test]
fn parse_full_p2_manifest() {
let toml = r#"
[metadata]
id = "erp-finance"
name = "财务/应收"
version = "0.1.0"
description = "财务管理与应收账款"
author = "ERP Team"
[schema]
[[schema.entities]]
name = "invoice"
display_name = "发票"
importable = true
exportable = true
[[schema.entities.fields]]
name = "invoice_no"
field_type = "string"
required = true
unique = true
display_name = "发票编号"
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
display_name = "客户"
ref_plugin = "erp-crm"
ref_entity = "customer"
ref_label_field = "name"
ref_search_fields = ["name"]
ref_fallback_label = "外部客户"
[[schema.entities]]
name = "payment"
display_name = "收款"
[settings]
[[settings.fields]]
name = "default_tax_rate"
display_name = "默认税率"
field_type = "number"
default_value = 0.13
group = "税务"
[[settings.fields]]
name = "invoice_prefix"
display_name = "发票前缀"
field_type = "text"
default_value = "INV"
[[numbering]]
entity = "invoice"
field = "invoice_no"
prefix = "INV"
format = "{PREFIX}-{YEAR}-{SEQ:4}"
reset_rule = "yearly"
[[trigger_events]]
name = "invoice.created"
display_name = "发票创建"
entity = "invoice"
on = "create"
[[permissions]]
code = "invoice.list"
name = "查看发票"
[[permissions]]
code = "invoice.manage"
name = "管理发票"
"#;
let manifest = parse_manifest(toml).unwrap();
assert_eq!(manifest.metadata.id, "erp-finance");
// settings
let settings = manifest.settings.unwrap();
assert_eq!(settings.fields.len(), 2);
// numbering
let numbering = manifest.numbering.unwrap();
assert_eq!(numbering.len(), 1);
assert_eq!(numbering[0].entity, "invoice");
// trigger_events
let triggers = manifest.trigger_events.unwrap();
assert_eq!(triggers.len(), 1);
// import/export on entity
let entities = &manifest.schema.unwrap().entities;
assert_eq!(entities[0].importable, Some(true));
assert_eq!(entities[0].exportable, Some(true));
}
#[test]
fn parse_dashboard_with_widgets() {
let toml = r##"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "invoice"
display_name = "发票"
[[schema.entities.fields]]
name = "status"
field_type = "string"
display_name = "状态"
[[schema.entities.fields]]
name = "amount"
field_type = "decimal"
display_name = "金额"
[ui]
[[ui.pages]]
type = "dashboard"
label = "工作台"
icon = "DashboardOutlined"
[[ui.pages.widgets]]
type = "stat_cards"
label = "财务概览"
[[ui.pages.widgets.cards]]
entity = "invoice"
aggregate = "count"
label = "总发票"
icon = "FileTextOutlined"
color = "#1890ff"
[[ui.pages.widgets.cards]]
entity = "invoice"
aggregate = "sum"
field = "amount"
filter = "status == 'pending'"
label = "待收金额"
icon = "DollarOutlined"
color = "#faad14"
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
[[ui.pages.widgets.queries]]
entity = "invoice"
filter = "status == 'overdue'"
sort = "due_date asc"
label_field = "invoice_number"
subtitle_field = "amount"
action = "open_invoice"
icon = "warning"
[[ui.pages.widgets]]
type = "funnel"
label = "商机漏斗"
entity = "invoice"
lane_field = "status"
value_field = "amount"
lane_order = ["pending", "issued", "paid"]
[[ui.pages.widgets]]
type = "card_list"
label = "活跃项目"
entity = "invoice"
filter = "status == 'active'"
max_items = 10
title_field = "invoice_number"
subtitle_field = "amount"
tags = ["status"]
"##;
let manifest = parse_manifest(toml).unwrap();
let ui = manifest.ui.unwrap();
assert_eq!(ui.pages.len(), 1);
match &ui.pages[0] {
PluginPageType::Dashboard {
label, icon, widgets,
} => {
assert_eq!(label, "工作台");
assert_eq!(icon.as_deref(), Some("DashboardOutlined"));
assert_eq!(widgets.len(), 4);
// stat_cards
match &widgets[0] {
PluginWidget::StatCards { label, cards } => {
assert_eq!(label, "财务概览");
assert_eq!(cards.len(), 2);
assert_eq!(cards[0].entity, "invoice");
assert_eq!(cards[0].aggregate.as_deref(), Some("count"));
assert_eq!(cards[1].aggregate.as_deref(), Some("sum"));
assert_eq!(cards[1].filter.as_deref(), Some("status == 'pending'"));
}
_ => panic!("Expected StatCards"),
}
// action_list
match &widgets[1] {
PluginWidget::ActionList {
label, max_items, queries,
} => {
assert_eq!(label, "紧急待办");
assert_eq!(*max_items, Some(5));
assert_eq!(queries.len(), 1);
assert_eq!(queries[0].entity, "invoice");
assert_eq!(queries[0].action, "open_invoice");
}
_ => panic!("Expected ActionList"),
}
// funnel
match &widgets[2] {
PluginWidget::Funnel {
label, entity, lane_field, value_field, lane_order,
} => {
assert_eq!(label, "商机漏斗");
assert_eq!(entity, "invoice");
assert_eq!(lane_field, "status");
assert_eq!(value_field.as_deref(), Some("amount"));
assert_eq!(lane_order, &["pending", "issued", "paid"]);
}
_ => panic!("Expected Funnel"),
}
// card_list
match &widgets[3] {
PluginWidget::CardList {
label, entity, title_field, ..
} => {
assert_eq!(label, "活跃项目");
assert_eq!(entity, "invoice");
assert_eq!(title_field, "invoice_number");
}
_ => panic!("Expected CardList"),
}
}
_ => panic!("Expected Dashboard page type"),
}
}
}