- Base platform from base.git (ERP base: auth, core, config, message, workflow, plugin) - Created erp-diary module skeleton (lib.rs, dto.rs, error.rs, event.rs, state.rs) - Integrated erp-diary into workspace and erp-server - Added DiaryModule registration in main.rs - Added DiaryState FromRef in state.rs - Diary routes mounted (empty routes, ready for implementation) - Product design spec v1.2 preserved in docs/ - Implementation plan preserved in plans/ Cargo check: OK Cargo test: OK (78+ base tests passing)
1810 lines
47 KiB
Rust
1810 lines
47 KiB
Rust
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 不是 immutable,generated 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 enum,TOML 中通过 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"),
|
||
}
|
||
}
|
||
}
|