feat(plugin): 集成 WASM 插件系统到主服务并修复链路问题
- 新增 erp-plugin crate:插件管理、WASM 运行时、动态表、数据 CRUD - 新增前端插件管理页面(PluginAdmin/PluginCRUDPage)和 API 层 - 新增插件数据迁移(plugins/plugin_entities/plugin_event_subscriptions) - 新增权限补充迁移(为已有租户补充 plugin.admin/plugin.list 权限) - 修复 PluginAdmin 页面 InstallOutlined 图标不存在的崩溃问题 - 修复 settings 唯一索引迁移顺序错误(先去重再建索引) - 更新 wiki 和 CLAUDE.md 反映插件系统集成状态 - 新增 dev.ps1 一键启动脚本
This commit is contained in:
262
crates/erp-plugin/src/manifest.rs
Normal file
262
crates/erp-plugin/src/manifest.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
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>>,
|
||||
}
|
||||
|
||||
/// 插件元数据
|
||||
#[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>,
|
||||
}
|
||||
|
||||
/// 插件字段定义
|
||||
#[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>>,
|
||||
}
|
||||
|
||||
/// 字段类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginFieldType {
|
||||
String,
|
||||
Integer,
|
||||
Float,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Json,
|
||||
Uuid,
|
||||
Decimal,
|
||||
}
|
||||
|
||||
/// 索引定义
|
||||
#[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)]
|
||||
pub struct PluginEvents {
|
||||
pub subscribe: Vec<String>,
|
||||
}
|
||||
|
||||
/// UI 页面配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginUi {
|
||||
pub pages: Vec<PluginPage>,
|
||||
}
|
||||
|
||||
/// 插件页面定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginPage {
|
||||
pub route: String,
|
||||
pub entity: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub icon: String,
|
||||
#[serde(default)]
|
||||
pub menu_group: Option<String>,
|
||||
}
|
||||
|
||||
/// 权限定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginPermission {
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// 从 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
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
#[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]]
|
||||
route = "/products"
|
||||
entity = "product"
|
||||
display_name = "商品管理"
|
||||
icon = "ShoppingOutlined"
|
||||
menu_group = "进销存"
|
||||
|
||||
[[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);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user