1603 lines
46 KiB
Markdown
1603 lines
46 KiB
Markdown
# CRM 客户管理插件实施计划
|
||
|
||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 实施 ERP 平台第一个行业插件——CRM 客户管理,同时增强基座的插件系统(过滤查询、动态权限、配置驱动 UI)。
|
||
|
||
**Architecture:** WASM 插件 + JSONB 动态表 + 配置驱动前端。先修基座(Phase 1),再建插件(Phase 2),最后高级功能(Phase 3)。
|
||
|
||
**Tech Stack:** Rust (Axum + SeaORM + Wasmtime) / TypeScript (React 18 + Ant Design + Zustand) / PostgreSQL (JSONB)
|
||
|
||
**Design Spec:** `docs/superpowers/specs/2026-04-16-crm-plugin-design.md` v1.1
|
||
|
||
---
|
||
|
||
## Chunk 1: Phase 1 — Rust 后端 Bug 修复与查询增强
|
||
|
||
### Task 1: 修复唯一索引 Bug
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-plugin/src/dynamic_table.rs:67-87`
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
在 `crates/erp-plugin/src/dynamic_table.rs` 底部的 `#[cfg(test)]` 模块中添加:
|
||
|
||
```rust
|
||
#[test]
|
||
fn test_unique_index_sql_uses_create_unique_index() {
|
||
// 验证 unique 字段生成的 SQL 包含 UNIQUE 关键字
|
||
let entity = PluginEntity {
|
||
name: "test".to_string(),
|
||
display_name: "Test".to_string(),
|
||
fields: vec![PluginField {
|
||
name: "code".to_string(),
|
||
field_type: PluginFieldType::String,
|
||
required: true,
|
||
unique: true,
|
||
default: None,
|
||
display_name: "Code".to_string(),
|
||
ui_widget: None,
|
||
options: None,
|
||
}],
|
||
indexes: vec![],
|
||
};
|
||
// 验证 create_table 生成的 SQL 中 unique 字段的索引包含 UNIQUE
|
||
let sql = DynamicTableManager::build_unique_index_sql("plugin_test", &entity.fields[0]);
|
||
assert!(sql.contains("CREATE UNIQUE INDEX"), "Expected UNIQUE index, got: {}", sql);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Run: `cargo test -p erp-plugin -- test_unique_index_sql_uses_create_unique_index`
|
||
Expected: 编译失败或测试失败(因为 `build_unique_index_sql` 方法尚不存在)
|
||
|
||
- [ ] **Step 3: 实现修复**
|
||
|
||
在 `crates/erp-plugin/src/dynamic_table.rs` 中:
|
||
|
||
1. 在 `create_table` 方法(第 67-87 行区域)中,将 `unique` 字段的索引创建从 `CREATE INDEX` 改为 `CREATE UNIQUE INDEX`。修改索引创建循环中判断 `field.unique` 的分支:
|
||
|
||
```rust
|
||
// 在 create_table 方法的索引创建循环中
|
||
if field.unique {
|
||
let idx_name = format!("{}_{}_uniq", table_name, field.name);
|
||
let idx_sql = format!(
|
||
"CREATE UNIQUE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (data->>'{}')",
|
||
idx_name, table_name, field.name
|
||
);
|
||
// 执行 idx_sql...
|
||
}
|
||
```
|
||
|
||
2. 添加辅助方法 `build_unique_index_sql` 供测试使用:
|
||
|
||
```rust
|
||
pub fn build_unique_index_sql(table_name: &str, field: &PluginField) -> String {
|
||
let idx_name = format!("{}_{}_uniq", table_name, field.name);
|
||
format!(
|
||
"CREATE UNIQUE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (data->>'{}')",
|
||
idx_name, table_name, field.name
|
||
)
|
||
}
|
||
```
|
||
|
||
3. **无需在 data_service.rs 手动处理 23505 错误**。`erp-core/src/error.rs` 的 `From<sea_orm::DbErr> for AppError` 已经将 `duplicate key` 错误自动转换为 `AppError::Conflict("记录已存在")`。`data_service.rs` 使用 `?` 传播错误,会自动走这个转换路径。
|
||
|
||
- [ ] **Step 4: 运行测试确认通过**
|
||
|
||
Run: `cargo test -p erp-plugin -- test_unique_index_sql_uses_create_unique_index`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-plugin/src/dynamic_table.rs crates/erp-plugin/src/data_service.rs
|
||
git commit -m "fix(plugin): 修复唯一索引使用 CREATE UNIQUE INDEX 并捕获 23505 错误"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: 修复插件权限注册(跨 crate 方案)
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-core/src/module.rs` — ErpModule trait 新增 `register_permissions` 方法
|
||
- Modify: `crates/erp-plugin/src/module.rs` — PluginModule 实现权限注册
|
||
- Modify: `crates/erp-server/src/main.rs` — 模块注册时调用权限注册
|
||
|
||
**架构约束**:`erp-plugin` 不能直接依赖 `erp-auth`(业务 crate 间禁止直接依赖)。解决方案:通过 `ErpModule` trait 新增方法,在 `erp-server`(唯一组装点)中桥接。
|
||
|
||
- [ ] **Step 1: 在 ErpModule trait 中新增权限注册方法**
|
||
|
||
在 `crates/erp-core/src/module.rs` 的 `ErpModule` trait 中添加:
|
||
|
||
```rust
|
||
/// 返回此模块需要注册的权限列表,在 install/启用时由 ModuleRegistry 调用
|
||
fn permissions(&self) -> Vec<PermissionDescriptor> {
|
||
vec![]
|
||
}
|
||
|
||
/// 返回此模块需要清理的权限前缀,在 uninstall 时调用
|
||
fn permission_prefix(&self) -> Option<String> {
|
||
None
|
||
}
|
||
```
|
||
|
||
在 `erp-core` 中定义 `PermissionDescriptor`:
|
||
|
||
```rust
|
||
#[derive(Clone, Debug)]
|
||
pub struct PermissionDescriptor {
|
||
pub code: String,
|
||
pub name: String,
|
||
pub description: String,
|
||
pub module: String,
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 PluginModule 中实现 permissions()**
|
||
|
||
在 `crates/erp-plugin/src/module.rs` 中,`PluginModule` 实现从已加载插件的 manifest 中提取权限:
|
||
|
||
```rust
|
||
fn permissions(&self) -> Vec<PermissionDescriptor> {
|
||
// 从 engine 中获取所有已安装插件的 manifest
|
||
// 遍历 manifest.permissions,生成 PermissionDescriptor 列表
|
||
// module 字段设为 format!("plugin:{}", plugin_id)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 在 ModuleRegistry 中添加权限注册流程**
|
||
|
||
在 `crates/erp-core/src/module.rs` 的 `ModuleRegistry` 中添加:
|
||
|
||
```rust
|
||
pub async fn register_permissions(&self, tenant_id: Uuid, operator_id: Uuid, db: &DatabaseConnection) -> AppResult<()> {
|
||
for module in &self.modules {
|
||
let perms = module.permissions();
|
||
for perm in perms {
|
||
// 使用 raw SQL 插入到 permissions 表
|
||
// INSERT INTO permissions (id, tenant_id, code, name, description, module, ...)
|
||
// VALUES ($1, $2, $3, $4, $5, $6, ...)
|
||
// ON CONFLICT DO NOTHING
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
使用 raw SQL 而非 entity 操作,避免 `erp-core` 依赖 `erp-auth`。
|
||
|
||
- [ ] **Step 4: 在 install 流程中调用权限注册**
|
||
|
||
在 `crates/erp-plugin/src/service.rs` 的 `install` 方法中,调用 `registry.register_permissions()` 注册新插件的权限。在 `uninstall` 中使用 raw SQL 清理。
|
||
|
||
- [ ] **Step 5: 运行编译检查**
|
||
|
||
Run: `cargo check --workspace`
|
||
Expected: 编译通过
|
||
|
||
- [ ] **Step 6: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-core/src/module.rs crates/erp-plugin/src/module.rs crates/erp-plugin/src/service.rs
|
||
git commit -m "fix(plugin): 通过 ErpModule trait 桥接实现插件权限注册,解决跨 crate 依赖问题"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: 扩展 Manifest Schema
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-plugin/src/manifest.rs:49-61` (PluginField)
|
||
- Modify: `crates/erp-plugin/src/manifest.rs:94-109` (PluginUi + PluginPage)
|
||
- Modify: `crates/erp-plugin/src/service.rs` (manifest 引用点)
|
||
- Modify: `crates/erp-plugin/src/host.rs` (manifest 引用点)
|
||
|
||
**迁移策略**:直接替换 `PluginPage` 结构体为 tagged enum `PluginPageType`,同步更新所有引用点。不保留旧结构体——现有 `parse_full_manifest` 测试使用旧的 `route/entity/display_name/icon/menu_group` 格式,需同步更新测试的 TOML 内容。这个 crate 还没有外部消费者,直接迁移最干净。
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
在 `crates/erp-plugin/src/manifest.rs` 测试模块中添加:
|
||
|
||
```rust
|
||
#[test]
|
||
fn test_parse_manifest_with_new_fields() {
|
||
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"),
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Expected: FAIL(新字段 `searchable`/`filterable`/`visible_when` 尚不存在,`PluginPageType` 尚未定义)
|
||
|
||
- [ ] **Step 3: 扩展 PluginField 结构体**
|
||
|
||
在 `manifest.rs` 的 `PluginField`(第 49-61 行)中新增字段。注意 `display_name` 现有类型是 `Option<String>`,保持不变:
|
||
|
||
```rust
|
||
#[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>, // 注意:现有是 Option<String>
|
||
pub ui_widget: Option<String>,
|
||
pub options: Option<Vec<serde_json::Value>>,
|
||
// 新增字段 — 全部 Optional + serde(default) 保证向后兼容
|
||
#[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>,
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 替换 PluginPage 为 tagged enum PluginPageType**
|
||
|
||
直接删除 `PluginPage`(第 100-109 行),替换为:
|
||
|
||
```rust
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(tag = "type")]
|
||
pub enum PluginPageType {
|
||
#[serde(rename = "crud")]
|
||
Crud {
|
||
entity: String,
|
||
label: String,
|
||
icon: Option<String>,
|
||
enable_search: Option<bool>,
|
||
enable_views: Option<Vec<String>>,
|
||
},
|
||
#[serde(rename = "tree")]
|
||
Tree {
|
||
entity: String,
|
||
label: String,
|
||
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,
|
||
icon: Option<String>,
|
||
tabs: Vec<PluginPageType>,
|
||
},
|
||
}
|
||
|
||
#[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,
|
||
filter_field: Option<String>,
|
||
enable_views: Option<Vec<String>>,
|
||
},
|
||
}
|
||
```
|
||
|
||
更新 `PluginUi`:
|
||
|
||
```rust
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct PluginUi {
|
||
pub pages: Vec<PluginPageType>,
|
||
}
|
||
```
|
||
|
||
**删除旧的 `PluginPage` 结构体**(第 100-109 行)。
|
||
|
||
- [ ] **Step 5: 更新所有引用点**
|
||
|
||
搜索 `PluginPage` 的所有使用位置并更新:
|
||
|
||
1. **`service.rs`** — `install()` 中如果遍历了 `ui.pages`,更新字段访问方式(改为 match 分支)
|
||
2. **`host.rs`** — 如果引用了 `PluginPage`,更新为新 enum
|
||
3. **`data_handler.rs`** — 同上
|
||
|
||
使用 `grep -rn "PluginPage" crates/erp-plugin/src/` 查找所有引用。
|
||
|
||
- [ ] **Step 6: 更新现有测试**
|
||
|
||
更新 `parse_full_manifest` 测试中的 TOML,将旧格式:
|
||
|
||
```toml
|
||
[[ui.pages]]
|
||
route = "/products"
|
||
entity = "product"
|
||
display_name = "商品管理"
|
||
icon = "ShoppingOutlined"
|
||
menu_group = "进销存"
|
||
```
|
||
|
||
改为新格式:
|
||
|
||
```toml
|
||
[[ui.pages]]
|
||
type = "crud"
|
||
entity = "product"
|
||
label = "商品管理"
|
||
icon = "ShoppingOutlined"
|
||
```
|
||
|
||
同时更新断言(`display_name` → `label`,去掉 `route`/`menu_group`)。
|
||
|
||
- [ ] **Step 7: 添加页面类型验证**
|
||
|
||
在 `parse_manifest` 中添加:
|
||
|
||
```rust
|
||
if let Some(ui) = &manifest.ui {
|
||
for page in &ui.pages {
|
||
match page {
|
||
PluginPageType::Crud { entity, .. } => {
|
||
if entity.is_empty() {
|
||
return Err(PluginError::InvalidManifest("crud page 的 entity 不能为空".into()));
|
||
}
|
||
}
|
||
PluginPageType::Tree { 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/parent/label_field 不能为空".into()));
|
||
}
|
||
}
|
||
PluginPageType::Detail { entity, sections } => {
|
||
if entity.is_empty() || sections.is_empty() {
|
||
return Err(PluginError::InvalidManifest("detail page 的 entity 和 sections 不能为空".into()));
|
||
}
|
||
}
|
||
PluginPageType::Tabs { tabs, .. } => {
|
||
if tabs.is_empty() {
|
||
return Err(PluginError::InvalidManifest("tabs page 的 tabs 不能为空".into()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: 运行全部测试**
|
||
|
||
Run: `cargo test -p erp-plugin`
|
||
Expected: ALL PASS
|
||
|
||
Run: `cargo check --workspace`
|
||
Expected: 编译通过
|
||
|
||
- [ ] **Step 9: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-plugin/src/
|
||
git commit -m "feat(plugin): 扩展 manifest schema 支持 searchable/filterable/visible_when 和 tagged enum 页面类型"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: 实现过滤查询 SQL 构建器
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-plugin/src/dynamic_table.rs` (新增方法)
|
||
- Modify: `crates/erp-plugin/src/data_dto.rs:28-33` (PluginDataListParams)
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
```rust
|
||
#[test]
|
||
fn test_build_filtered_query_sql_with_filter() {
|
||
let sql_result = DynamicTableManager::build_filtered_query_sql(
|
||
"plugin_test_customer",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
20,
|
||
0,
|
||
Some(serde_json::json!({"customer_id": "abc-123"})),
|
||
None,
|
||
None,
|
||
None,
|
||
);
|
||
let (sql, params) = sql_result;
|
||
assert!(sql.contains("data->>'customer_id' ="), "Expected filter in SQL, got: {}", sql);
|
||
assert!(sql.contains("tenant_id"), "Expected tenant_id filter");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_filtered_query_sql_sanitizes_keys() {
|
||
// 恶意 key 应被拒绝
|
||
let result = DynamicTableManager::build_filtered_query_sql(
|
||
"plugin_test",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
20, 0,
|
||
Some(serde_json::json!({"evil'; DROP TABLE--": "value"})),
|
||
None, None, None,
|
||
);
|
||
assert!(result.is_err() || !result.as_ref().unwrap().0.contains("DROP TABLE"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_filtered_query_sql_with_search() {
|
||
let (sql, _) = DynamicTableManager::build_filtered_query_sql(
|
||
"plugin_test",
|
||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
20, 0, None,
|
||
Some(("name,code".to_string(), "测试关键词".to_string())),
|
||
None, None,
|
||
);
|
||
assert!(sql.contains("ILIKE"), "Expected ILIKE in SQL, got: {}", sql);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Run: `cargo test -p erp-plugin -- test_build_filtered_query_sql`
|
||
Expected: FAIL(方法不存在)
|
||
|
||
- [ ] **Step 3: 扩展 PluginDataListParams**
|
||
|
||
在 `data_dto.rs` 中:
|
||
|
||
```rust
|
||
#[derive(Debug, Deserialize, IntoParams)]
|
||
pub struct PluginDataListParams {
|
||
pub page: Option<u64>,
|
||
pub page_size: Option<u64>,
|
||
pub search: Option<String>,
|
||
// 新增
|
||
pub filter: Option<String>, // JSON 格式: {"field":"value"}
|
||
pub sort_by: Option<String>,
|
||
pub sort_order: Option<String>, // "asc" or "desc"
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 实现 build_filtered_query_sql**
|
||
|
||
在 `dynamic_table.rs` 中新增方法:
|
||
|
||
```rust
|
||
pub fn build_filtered_query_sql(
|
||
table_name: &str,
|
||
tenant_id: Uuid,
|
||
limit: u64,
|
||
offset: u64,
|
||
filter: Option<serde_json::Value>,
|
||
search: Option<(String, String)>, // (searchable_fields_csv, keyword)
|
||
sort_by: Option<String>,
|
||
sort_order: Option<String>,
|
||
) -> Result<(String, Vec<Value>), String> {
|
||
let mut conditions = vec![
|
||
format!("\"tenant_id\" = ${}", 1),
|
||
format!("\"deleted_at\" IS NULL"),
|
||
];
|
||
let mut param_idx = 2;
|
||
let mut values: Vec<Value> = vec![tenant_id.into()];
|
||
|
||
// 处理 filter
|
||
if let Some(f) = filter {
|
||
if let Some(obj) = f.as_object() {
|
||
for (key, val) in obj {
|
||
let clean_key = sanitize_identifier(key);
|
||
if clean_key.is_empty() {
|
||
return Err(format!("无效的过滤字段名: {}", key));
|
||
}
|
||
conditions.push(format!("\"data\"->>'{}' = ${}", clean_key, param_idx));
|
||
values.push(Value::String(Some(Box::new(val.as_str().unwrap_or("").to_string()))));
|
||
param_idx += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理 search — 所有字段共享同一个 ILIKE 参数(同一个关键词)
|
||
if let Some((fields_csv, keyword)) = search {
|
||
let escaped = keyword.replace('%', "\\%").replace('_', "\\_");
|
||
let fields: Vec<&str> = fields_csv.split(',').collect();
|
||
let search_param_idx = param_idx;
|
||
let search_conditions: Vec<String> = fields.iter().map(|f| {
|
||
let clean = sanitize_identifier(f.trim());
|
||
format!("\"data\"->>'{}' ILIKE ${}", clean, search_param_idx)
|
||
}).collect();
|
||
conditions.push(format!("({})", search_conditions.join(" OR ")));
|
||
values.push(Value::String(Some(Box::new(format!("%{}%", escaped)))));
|
||
param_idx += 1;
|
||
}
|
||
|
||
// 处理 sort
|
||
let order_clause = if let Some(sb) = sort_by {
|
||
let clean = sanitize_identifier(&sb);
|
||
let dir = match sort_order.as_deref() {
|
||
Some("asc") | Some("ASC") => "ASC",
|
||
_ => "DESC",
|
||
};
|
||
format!("ORDER BY \"data\"->>'{}' {}", clean, dir)
|
||
} else {
|
||
"ORDER BY \"created_at\" DESC".to_string()
|
||
};
|
||
|
||
let sql = format!(
|
||
"SELECT * FROM \"{}\" WHERE {} {} LIMIT ${} OFFSET ${}",
|
||
table_name,
|
||
conditions.join(" AND "),
|
||
order_clause,
|
||
param_idx,
|
||
param_idx + 1,
|
||
);
|
||
values.push(limit.into());
|
||
values.push(offset.into());
|
||
|
||
Ok((sql, values))
|
||
}
|
||
```
|
||
|
||
注意:`Value` 类型即 `sea_orm::Value`(文件头部已有 `use sea_orm::Value`)。`Value::String(Some(Box::new(...)))` 是 SeaORM 的 `String` variant 包装方式。
|
||
|
||
- [ ] **Step 5: 运行测试确认通过**
|
||
|
||
Run: `cargo test -p erp-plugin -- test_build_filtered_query_sql`
|
||
Expected: PASS
|
||
|
||
Run: `cargo check -p erp-plugin`
|
||
Expected: 编译通过
|
||
|
||
- [ ] **Step 6: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-plugin/src/dynamic_table.rs crates/erp-plugin/src/data_dto.rs
|
||
git commit -m "feat(plugin): 实现过滤查询 SQL 构建器,支持 filter/search/sort"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: 集成过滤查询到 data_service 和 handler
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-plugin/src/data_service.rs:57-116` (list 方法)
|
||
- Modify: `crates/erp-plugin/src/handler/data_handler.rs:25-57` (list handler)
|
||
|
||
- [ ] **Step 1: 修改 data_service.rs 的 list 方法签名**
|
||
|
||
将 `list` 方法签名从:
|
||
|
||
```rust
|
||
pub async fn list(plugin_id: Uuid, entity_name: &str, tenant_id: Uuid, page: u64, page_size: u64, db: &DatabaseConnection) -> AppResult<(Vec<PluginDataResp>, u64)>
|
||
```
|
||
|
||
改为:
|
||
|
||
```rust
|
||
pub async fn list(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
tenant_id: Uuid,
|
||
page: u64,
|
||
page_size: u64,
|
||
db: &DatabaseConnection,
|
||
filter: Option<serde_json::Value>,
|
||
search: Option<String>,
|
||
search_fields: Option<String>,
|
||
sort_by: Option<String>,
|
||
sort_order: Option<String>,
|
||
) -> AppResult<(Vec<PluginDataResp>, u64)>
|
||
```
|
||
|
||
在方法内部,将 `DynamicTableManager::build_query_sql` 替换为 `build_filtered_query_sql`。`search_fields` 参数来源:从 `plugin_entities` 表的 `schema_json` 字段反序列化出 entity 的 fields,筛选 `searchable == Some(true)` 的字段名,用逗号拼接成 CSV。如无可搜索字段则传 `None`。
|
||
|
||
```rust
|
||
// 在 list 方法内部获取 searchable fields
|
||
let entity_fields = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?;
|
||
let search_fields_csv: Option<String> = {
|
||
let searchable: Vec<&str> = entity_fields.iter()
|
||
.filter(|f| f.searchable == Some(true))
|
||
.map(|f| f.name.as_str())
|
||
.collect();
|
||
if searchable.is_empty() { None } else { Some(searchable.join(",")) }
|
||
};
|
||
let search_tuple = match (&search_fields_csv, &search) {
|
||
(Some(fields), Some(kw)) => Some((fields.clone(), kw.clone())),
|
||
_ => None,
|
||
};
|
||
let (sql, values) = DynamicTableManager::build_filtered_query_sql(
|
||
&table_name, tenant_id, page_size, offset, filter, search_tuple, sort_by, sort_order,
|
||
)?;
|
||
```
|
||
|
||
- [ ] **Step 2: 修改 data_handler.rs 的 list handler**
|
||
|
||
在 `list_plugin_data` 函数中,提取 `Query(params)` 并传递给 service。注意 handler **不需要**传 `search_fields`——service 层自己从 manifest 查找 searchable 字段:
|
||
|
||
```rust
|
||
let filter: Option<serde_json::Value> = params.filter.as_ref()
|
||
.and_then(|f| serde_json::from_str(f).ok());
|
||
let result = PluginDataService::list(
|
||
plugin_id, &entity, ctx.tenant_id, page, page_size,
|
||
&state.db, filter, params.search, params.sort_by, params.sort_order,
|
||
).await?;
|
||
```
|
||
|
||
- [ ] **Step 3: 修改 data_handler.rs 的动态权限检查**
|
||
|
||
将所有 handler 的 `require_permission` 从硬编码改为动态计算:
|
||
|
||
```rust
|
||
// 辅助函数:计算插件数据操作所需的权限码
|
||
// 格式:{plugin_id}.{entity}.{action},如 crm.customer.list
|
||
fn compute_permission_code(plugin_id: &str, entity_name: &str, action: &str) -> String {
|
||
let action_suffix = match action {
|
||
"list" | "get" => "list",
|
||
_ => "manage",
|
||
};
|
||
format!("{}.{}.{}", plugin_id, entity_name, action_suffix)
|
||
}
|
||
```
|
||
|
||
替换每个 handler 中的权限检查:
|
||
|
||
```rust
|
||
// 原来: require_permission(&ctx, "plugin.list")?;
|
||
// 改为:
|
||
let perm = compute_permission_code(&plugin_id, &entity_name, "list");
|
||
require_permission(&ctx, &perm)?;
|
||
```
|
||
|
||
- [ ] **Step 4: 运行编译检查**
|
||
|
||
Run: `cargo check -p erp-plugin`
|
||
Expected: 编译通过
|
||
|
||
Run: `cargo test -p erp-plugin`
|
||
Expected: ALL PASS
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-plugin/src/data_service.rs crates/erp-plugin/src/handler/data_handler.rs
|
||
git commit -m "feat(plugin): 集成过滤查询到 REST API,改造动态权限检查"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 添加 searchable 字段 GIN 索引
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-plugin/src/dynamic_table.rs` (create_table 方法)
|
||
|
||
- [ ] **Step 1: 在 create_table 中为 searchable 字段创建 GIN 索引**
|
||
|
||
在 `create_table` 方法的索引创建循环后,添加 searchable 字段的 GIN 索引:
|
||
|
||
```rust
|
||
// 为 searchable 字段创建 GIN 索引以加速 ILIKE 查询
|
||
for field in &entity.fields {
|
||
if field.searchable == Some(true) {
|
||
let idx_name = format!("{}_{}_gin", sanitized_table, field.name);
|
||
let gin_sql = format!(
|
||
"CREATE INDEX IF NOT EXISTS \"{}\" ON \"{}\" USING GIN (\"data\" gin_path_ops)",
|
||
idx_name, sanitized_table
|
||
);
|
||
// 执行 gin_sql...
|
||
}
|
||
}
|
||
```
|
||
|
||
注意:`gin_path_ops` 适用于 `@>` 操作符。对于 `ILIKE` 查询,可能需要 `btree(data->>'field')` 索引或 pg_trgm 扩展的 GIN 索引。根据实际查询模式选择合适的索引类型。
|
||
|
||
- [ ] **Step 2: 运行编译检查**
|
||
|
||
Run: `cargo check -p erp-plugin`
|
||
Expected: 编译通过
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-plugin/src/dynamic_table.rs
|
||
git commit -m "feat(plugin): 为 searchable 字段自动创建 GIN 索引"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 添加数据校验层
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-plugin/src/data_service.rs` (create/update 方法)
|
||
|
||
- [ ] **Step 1: 实现 resolve_entity_fields 辅助函数**
|
||
|
||
在 `data_service.rs` 中添加——从 `plugin_entities` 表的 `schema_json` 字段获取 entity 的字段定义:
|
||
|
||
```rust
|
||
use crate::manifest::PluginField;
|
||
|
||
/// 从 plugin_entities 表获取 entity 的字段定义
|
||
async fn resolve_entity_fields(
|
||
plugin_id: Uuid,
|
||
entity_name: &str,
|
||
tenant_id: Uuid,
|
||
db: &sea_orm::DatabaseConnection,
|
||
) -> AppResult<Vec<PluginField>> {
|
||
let entity_model = plugin_entity::Entity::find()
|
||
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
|
||
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||
.filter(plugin_entity::Column::EntityName.eq(entity_name))
|
||
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||
.one(db)
|
||
.await?
|
||
.ok_or_else(|| AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name)))?;
|
||
|
||
let entity_def: crate::manifest::PluginEntity =
|
||
serde_json::from_value(entity_model.schema_json)
|
||
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||
|
||
Ok(entity_def.fields)
|
||
}
|
||
```
|
||
|
||
注意:`resolve_entity_fields` 同时被 Task 5 的 `list` 方法和本 Task 的 `validate_data` 使用。
|
||
|
||
- [ ] **Step 2: 实现 validate_data 辅助函数**
|
||
|
||
```rust
|
||
fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<()> {
|
||
let obj = data.as_object()
|
||
.ok_or_else(|| AppError::Validation("data 必须是 JSON 对象".to_string()))?;
|
||
for field in fields {
|
||
if field.required && !obj.contains_key(&field.name) {
|
||
let label = field.display_name.as_deref().unwrap_or(&field.name);
|
||
return Err(AppError::Validation(format!("字段 '{}' 不能为空", label)));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
注意:`field.display_name` 类型是 `Option<String>`,使用 `as_deref().unwrap_or()` 提供回退值。
|
||
|
||
- [ ] **Step 3: 在 create/update 中调用验证**
|
||
|
||
在 `create` 方法中(在 `build_insert_sql` 之前):
|
||
|
||
```rust
|
||
let fields = resolve_entity_fields(plugin_id, entity_name, tenant_id, db).await?;
|
||
validate_data(&data, &fields)?;
|
||
```
|
||
|
||
在 `update` 方法中同理。
|
||
|
||
- [ ] **Step 4: 运行编译检查**
|
||
|
||
Run: `cargo check -p erp-plugin`
|
||
Expected: 编译通过
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-plugin/src/data_service.rs
|
||
git commit -m "feat(plugin): 添加数据校验层,检查 required 字段"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 2: Phase 1 — 前端 CRUD 增强、detail 页面、visible_when
|
||
|
||
### Task 8: 扩展前端 pluginData API
|
||
|
||
**Files:**
|
||
- Modify: `apps/web/src/api/pluginData.ts:19-30`
|
||
- Modify: `apps/web/src/api/plugins.ts:116-121`
|
||
|
||
- [ ] **Step 1: 扩展 listPluginData 函数签名**
|
||
|
||
将 `pluginData.ts` 的 `listPluginData` 改为:
|
||
|
||
```typescript
|
||
export interface PluginDataListOptions {
|
||
filter?: Record<string, string>;
|
||
search?: string;
|
||
sort_by?: string;
|
||
sort_order?: 'asc' | 'desc';
|
||
}
|
||
|
||
export async function listPluginData(
|
||
pluginId: string,
|
||
entity: string,
|
||
page = 1,
|
||
pageSize = 20,
|
||
options?: PluginDataListOptions
|
||
) {
|
||
const params: Record<string, string> = {
|
||
page: String(page),
|
||
page_size: String(pageSize),
|
||
};
|
||
if (options?.filter) params.filter = JSON.stringify(options.filter);
|
||
if (options?.search) params.search = options.search;
|
||
if (options?.sort_by) params.sort_by = options.sort_by;
|
||
if (options?.sort_order) params.sort_order = options.sort_order;
|
||
const { data } = await client.get<{
|
||
success: boolean;
|
||
data: PaginatedDataResponse<PluginDataRecord>;
|
||
}>(`/plugins/${pluginId}/${entity}`, { params });
|
||
return data.data;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 扩展 getPluginSchema 返回类型**
|
||
|
||
在 `plugins.ts` 中定义精确的 schema 类型:
|
||
|
||
```typescript
|
||
export interface PluginFieldSchema {
|
||
name: string;
|
||
field_type: string;
|
||
required: boolean;
|
||
display_name: string;
|
||
ui_widget?: string;
|
||
options?: { label: string; value: string }[];
|
||
searchable?: boolean;
|
||
filterable?: boolean;
|
||
sortable?: boolean;
|
||
visible_when?: string;
|
||
unique?: boolean;
|
||
}
|
||
|
||
export interface PluginEntitySchema {
|
||
name: string;
|
||
display_name: string;
|
||
fields: PluginFieldSchema[];
|
||
}
|
||
|
||
export interface PluginSchemaResponse {
|
||
entities: PluginEntitySchema[];
|
||
}
|
||
```
|
||
|
||
更新 `getPluginSchema` 返回类型。
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/api/pluginData.ts apps/web/src/api/plugins.ts
|
||
git commit -m "feat(web): 扩展插件 API 支持 filter/search/sort 参数和精确 schema 类型"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: 增强 PluginCRUDPage — 筛选/搜索/排序
|
||
|
||
**Files:**
|
||
- Modify: `apps/web/src/pages/PluginCRUDPage.tsx`
|
||
|
||
- [ ] **Step 1: 在表格上方添加搜索框和筛选栏**
|
||
|
||
在 `PluginCRUDPage` 组件中:
|
||
|
||
1. 添加 state:`searchText`, `filters`, `sortBy`, `sortOrder`
|
||
2. 从 schema fields 中提取 `filterable` 字段列表,渲染 `Select` 组件
|
||
3. 当 `enable_search` 为 true 时渲染 `Input.Search`
|
||
4. 修改 `fetchData` 传递 filter/search/sort 参数
|
||
|
||
```typescript
|
||
// 搜索和筛选栏(在 Table 上方)
|
||
<Space style={{ marginBottom: 16 }} wrap>
|
||
{enableSearch && (
|
||
<Input.Search
|
||
placeholder="搜索..."
|
||
allowClear
|
||
style={{ width: 240 }}
|
||
onSearch={(value) => { setSearchText(value); fetchData(1, pageSize, value, filters); }}
|
||
/>
|
||
)}
|
||
{filterableFields.map(field => (
|
||
<Select
|
||
key={field.name}
|
||
placeholder={field.display_name}
|
||
allowClear
|
||
style={{ width: 150 }}
|
||
options={field.options}
|
||
onChange={(value) => {
|
||
const newFilters = { ...filters };
|
||
if (value) newFilters[field.name] = value;
|
||
else delete newFilters[field.name];
|
||
setFilters(newFilters);
|
||
fetchData(1, pageSize, searchText, newFilters);
|
||
}}
|
||
/>
|
||
))}
|
||
</Space>
|
||
```
|
||
|
||
- [ ] **Step 2: 添加视图切换(timeline)**
|
||
|
||
当 `enable_views` 包含多个视图时,在右上角添加 `Segmented` 组件切换视图模式。Phase 1 先只实现 table 视图,timeline 留到 Phase 2。
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/PluginCRUDPage.tsx
|
||
git commit -m "feat(web): PluginCRUDPage 增加搜索框、筛选栏和排序支持"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: 实现 PluginDetailPage — Drawer + Tabs + 嵌套 CRUD
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/pages/PluginDetailPage.tsx`
|
||
|
||
- [ ] **Step 1: 创建 PluginDetailPage 组件**
|
||
|
||
```typescript
|
||
import { Drawer, Tabs, Descriptions, Table, Spin } from 'antd';
|
||
import { useState, useEffect } from 'react';
|
||
import { getPluginSchema, PluginEntitySchema } from '../api/plugins';
|
||
import { listPluginData, PluginDataRecord } from '../api/pluginData';
|
||
|
||
interface PluginDetailPageProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
pluginId: string;
|
||
entityId: string;
|
||
recordId: string;
|
||
recordData: Record<string, unknown>;
|
||
schema: PluginEntitySchema | null;
|
||
}
|
||
|
||
export function PluginDetailPage({ open, onClose, pluginId, entityId, recordId, recordData, schema }: PluginDetailPageProps) {
|
||
// 1. 获取完整的 plugin schema(包含所有 entity)
|
||
// 2. 找到 detail 页面配置的 sections
|
||
// 3. 渲染 Drawer + Tabs,每个 section 渲染不同内容
|
||
// ...
|
||
}
|
||
```
|
||
|
||
核心逻辑:
|
||
- `fields` section → `Descriptions` 组件展示字段值
|
||
- `crud` section → 嵌套的 `Table` 组件,通过 `filter_field` 自动过滤
|
||
|
||
- [ ] **Step 2: 在 PluginCRUDPage 中集成详情按钮**
|
||
|
||
当 manifest 中声明了同实体的 `detail` 页面时,在操作列自动添加"详情"按钮:
|
||
|
||
```typescript
|
||
// 在 PluginCRUDPage 的操作列中
|
||
{hasDetailPage && (
|
||
<Button type="link" size="small" onClick={() => setDetailRecord(record)}>
|
||
详情
|
||
</Button>
|
||
)}
|
||
```
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/PluginDetailPage.tsx apps/web/src/pages/PluginCRUDPage.tsx
|
||
git commit -m "feat(web): 新增 PluginDetailPage Drawer 详情页,支持嵌套 CRUD"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: 实现 visible_when 条件表单字段
|
||
|
||
**Files:**
|
||
- Modify: `apps/web/src/pages/PluginCRUDPage.tsx:166-192` (renderFormField)
|
||
|
||
- [ ] **Step 1: 实现 visible_when 解析函数**
|
||
|
||
```typescript
|
||
function parseVisibleWhen(expression: string): { field: string; value: string } | null {
|
||
const regex = /^(\w+)\s*==\s*'([^']*)'$/;
|
||
const match = expression.trim().match(regex);
|
||
if (!match) return null;
|
||
return { field: match[1], value: match[2] };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在表单渲染中集成条件显示**
|
||
|
||
在 `renderFormField` 中,如果字段有 `visible_when`,用 `Form.useWatch` 监听条件字段:
|
||
|
||
```typescript
|
||
// 在表单渲染的 map 中
|
||
{fields.map(field => {
|
||
const visible = field.visible_when
|
||
? shouldShowField(form.getFieldsValue(), field.visible_when)
|
||
: true;
|
||
if (!visible) return null;
|
||
return <Form.Item key={field.name} ...>{renderFormField(field)}</Form.Item>;
|
||
})}
|
||
```
|
||
|
||
需要在组件中添加 `form.getFieldsValue()` 的响应式监听。可以使用 `Form.useWatch` 或在 `onValuesChanged` 回调中触发重渲染。
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/PluginCRUDPage.tsx
|
||
git commit -m "feat(web): 表单字段支持 visible_when 条件显示"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Phase 1 端到端验证
|
||
|
||
- [ ] **Step 1: 启动 Docker 环境**
|
||
|
||
Run: `cd docker && docker compose up -d`
|
||
|
||
- [ ] **Step 2: 编译并启动后端**
|
||
|
||
Run: `cargo run -p erp-server`
|
||
|
||
- [ ] **Step 3: 启动前端**
|
||
|
||
Run: `cd apps/web && pnpm dev`
|
||
|
||
- [ ] **Step 4: 运行全量测试**
|
||
|
||
Run: `cargo test --workspace`
|
||
|
||
- [ ] **Step 5: 手动验证验收标准**
|
||
|
||
- [ ] 使用已有的测试插件验证:唯一字段重复插入返回冲突错误
|
||
- [ ] REST API 支持 `?filter={"field":"value"}&search=keyword`
|
||
- [ ] PluginCRUDPage 自动渲染筛选栏和搜索框
|
||
- [ ] visible_when 条件字段正常工作
|
||
|
||
---
|
||
|
||
## Chunk 3: Phase 2 — CRM WASM 插件核心
|
||
|
||
### Task 13: 创建 CRM 插件 crate 骨架
|
||
|
||
**Files:**
|
||
- Create: `crates/erp-plugin-crm/Cargo.toml`
|
||
- Create: `crates/erp-plugin-crm/src/lib.rs`
|
||
- Create: `crates/erp-plugin-crm/plugin.toml`
|
||
- Modify: `Cargo.toml` (workspace members)
|
||
|
||
- [ ] **Step 1: 创建 Cargo.toml**
|
||
|
||
```toml
|
||
[package]
|
||
name = "erp-plugin-crm"
|
||
version = "0.1.0"
|
||
edition = "2024"
|
||
|
||
[lib]
|
||
crate-type = ["cdylib"]
|
||
|
||
[dependencies]
|
||
wit-bindgen = "0.55"
|
||
serde = { version = "1", features = ["derive"] }
|
||
serde_json = "1"
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 src/lib.rs**
|
||
|
||
参考 `erp-plugin-test-sample` 的结构,实现 Guest trait 的三个方法:
|
||
|
||
```rust
|
||
wit_bindgen::generate!({
|
||
path: "../erp-plugin-prototype/wit/plugin.wit",
|
||
world: "plugin-world",
|
||
});
|
||
|
||
use crate::exports::erp::plugin::plugin_api::Guest;
|
||
|
||
struct CrmPlugin;
|
||
|
||
impl Guest for CrmPlugin {
|
||
fn init() -> Result<(), String> {
|
||
Ok(())
|
||
}
|
||
|
||
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
|
||
let _ = tenant_id;
|
||
Ok(())
|
||
}
|
||
|
||
fn handle_event(event_type: String, payload: Vec<u8>) -> Result<(), String> {
|
||
let _ = (event_type, payload);
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
export!(CrmPlugin);
|
||
```
|
||
|
||
注意:`wit_bindgen::generate!` 的 path 指向 `erp-plugin-prototype`(非 `erp-plugin`),因为 WIT 文件在 prototype crate 中。`export!` 宏由 `generate!` 自动生成。完整参考 `crates/erp-plugin-test-sample/src/lib.rs`。
|
||
|
||
- [ ] **Step 3: 创建 plugin.toml**
|
||
|
||
使用设计规格 §4.1 中完整的 manifest 配置。
|
||
|
||
- [ ] **Step 4: 注册到 workspace**
|
||
|
||
在根 `Cargo.toml` 的 `[workspace] members` 中添加 `"crates/erp-plugin-crm"`。
|
||
|
||
- [ ] **Step 5: 编译 WASM**
|
||
|
||
Run: `cargo build -p erp-plugin-crm --target wasm32-unknown-unknown --release`
|
||
Expected: 编译成功,生成 `.wasm` 文件
|
||
|
||
- [ ] **Step 6: 转换为 Component**
|
||
|
||
Run: `wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_crm.wasm -o target/erp_plugin_crm.component.wasm`
|
||
|
||
- [ ] **Step 7: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-plugin-crm/ Cargo.toml
|
||
git commit -m "feat(crm): 创建 CRM 插件 crate 骨架和 manifest"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: 通过 PluginAdmin 上传并测试 CRM 插件
|
||
|
||
- [ ] **Step 1: 上传插件**
|
||
|
||
通过 PluginAdmin 页面上传 `erp_plugin_crm.component.wasm` 和 `plugin.toml`。
|
||
|
||
- [ ] **Step 2: 安装插件**
|
||
|
||
验证:5 个动态表创建成功、9 个权限注册成功。
|
||
|
||
- [ ] **Step 3: 启用插件**
|
||
|
||
验证:WASM 初始化成功,侧边栏出现 CRM 菜单项。
|
||
|
||
- [ ] **Step 4: 基础 CRUD 测试**
|
||
|
||
- 创建客户(企业/个人)
|
||
- 创建联系人(关联客户)
|
||
- 创建沟通记录(关联客户)
|
||
- 列表查询(验证 filter 按客户过滤)
|
||
- 编辑/删除
|
||
|
||
---
|
||
|
||
## Chunk 4: Phase 2 — 前端 tree/timeline/tabs 页面类型
|
||
|
||
### Task 15: 实现 PluginTabsPage
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/pages/PluginTabsPage.tsx`
|
||
|
||
- [ ] **Step 1: 创建 tabs 容器组件**
|
||
|
||
使用 Ant Design `Tabs` 组件,每个 tab 根据子页面类型渲染对应组件:
|
||
|
||
```typescript
|
||
export function PluginTabsPage({ pluginId, tabs, schema }: TabsPageProps) {
|
||
const [activeKey, setActiveKey] = useState(tabs[0]?.label);
|
||
|
||
return (
|
||
<Tabs activeKey={activeKey} onChange={setActiveKey} items={tabs.map(tab => ({
|
||
key: tab.label,
|
||
label: tab.label,
|
||
children: renderPageContent(pluginId, tab, schema),
|
||
}))} />
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/PluginTabsPage.tsx
|
||
git commit -m "feat(web): 新增 PluginTabsPage 通用标签页容器"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: 实现 PluginTreePage
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/pages/PluginTreePage.tsx`
|
||
|
||
- [ ] **Step 1: 创建树形页面组件**
|
||
|
||
使用 Ant Design `DirectoryTree`,从 REST API 加载全量数据,前端构建树结构:
|
||
|
||
```typescript
|
||
export function PluginTreePage({ pluginId, config, schema }: TreePageProps) {
|
||
// config: { entity, id_field, parent_field, label_field }
|
||
// 1. 加载全量数据
|
||
// 2. 构建树结构(根据 parent_field 建立父子关系)
|
||
// 3. 渲染 DirectoryTree
|
||
// 4. 点击节点显示右侧详情面板
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/PluginTreePage.tsx
|
||
git commit -m "feat(web): 新增 PluginTreePage 通用树形页面"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: 实现 timeline 视图模式
|
||
|
||
**Files:**
|
||
- Modify: `apps/web/src/pages/PluginCRUDPage.tsx`
|
||
|
||
- [ ] **Step 1: 添加 timeline 视图渲染**
|
||
|
||
在 PluginCRUDPage 中,当 `enable_views` 包含 `"timeline"` 时,添加 timeline 渲染逻辑:
|
||
|
||
```typescript
|
||
const renderTimeline = () => (
|
||
<Timeline items={data.map(item => ({
|
||
children: (
|
||
<div>
|
||
<p><strong>{item[titleField]}</strong></p>
|
||
<p>{item[contentField]}</p>
|
||
<p style={{ color: '#999' }}>{item[dateField]}</p>
|
||
</div>
|
||
),
|
||
color: getTypeColor(item[typeField]),
|
||
}))} />
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 2: 集成 Segmented 切换**
|
||
|
||
```typescript
|
||
{enableViews.length > 1 && (
|
||
<Segmented
|
||
options={enableViews.map(v => ({ label: v === 'table' ? '表格' : '时间线', value: v }))}
|
||
value={viewMode}
|
||
onChange={setViewMode}
|
||
/>
|
||
)}
|
||
{viewMode === 'table' ? <Table ... /> : renderTimeline()}
|
||
```
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/PluginCRUDPage.tsx
|
||
git commit -m "feat(web): PluginCRUDPage 添加 timeline 视图模式切换"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 18: 侧边栏动态菜单集成
|
||
|
||
**Files:**
|
||
- Modify: `apps/web/src/stores/plugin.ts:38-58` (refreshMenuItems)
|
||
- Modify: `apps/web/src/layouts/MainLayout.tsx:172-192` (插件菜单渲染)
|
||
- Modify: `apps/web/src/pages/PluginCRUDPage.tsx` 或 `App.tsx` (路由)
|
||
|
||
- [ ] **Step 1: 修改 plugin store 的菜单生成逻辑**
|
||
|
||
从 manifest 的 `ui.pages` 生成菜单项(而非仅遍历 entities):
|
||
|
||
```typescript
|
||
// refreshMenuItems 中
|
||
for (const plugin of plugins) {
|
||
if (plugin.status !== 'running') continue;
|
||
const schema = await getPluginSchema(plugin.id); // 或从缓存读取
|
||
for (const page of schema.ui?.pages || []) {
|
||
if (page.type === 'tabs') {
|
||
// tabs 类型聚合为一个菜单项
|
||
items.push({
|
||
key: `/plugins/${plugin.id}/${page.label}`,
|
||
label: page.label,
|
||
icon: page.icon,
|
||
pluginId: plugin.id,
|
||
pageType: 'tabs',
|
||
});
|
||
} else if (page.type !== 'detail') {
|
||
// crud/tree 各生成一个菜单项
|
||
items.push({
|
||
key: `/plugins/${plugin.id}/${page.entity}`,
|
||
label: page.label,
|
||
icon: page.icon,
|
||
pluginId: plugin.id,
|
||
entity: page.entity,
|
||
pageType: page.type,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 修改 MainLayout 支持动态图标**
|
||
|
||
将 182 行的固定 `<AppstoreOutlined />` 改为根据 `item.icon` 选择图标。
|
||
|
||
- [ ] **Step 3: 修改路由分发**
|
||
|
||
在 `App.tsx` 或路由配置中,根据 `pageType` 匹配不同的页面组件:
|
||
- `crud` → `PluginCRUDPage`
|
||
- `tabs` → `PluginTabsPage`
|
||
- `tree` → `PluginTreePage`
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/stores/plugin.ts apps/web/src/layouts/MainLayout.tsx apps/web/src/App.tsx
|
||
git commit -m "feat(web): 侧边栏动态菜单集成,支持 manifest pages 配置"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 19: Phase 2 端到端验证
|
||
|
||
- [ ] **Step 1: 启动全栈环境**
|
||
|
||
Run: `.\dev.ps1`
|
||
|
||
- [ ] **Step 2: 验收 CRM 插件**
|
||
|
||
- 上传 → 安装 → 启用,侧边栏出现 CRM 菜单("客户管理"、"联系人"、"沟通记录"、"标签管理"、"客户关系")
|
||
- 客户 CRUD:创建企业客户、个人客户,验证差异化字段
|
||
- 客户详情 Drawer:点击详情按钮,看到关联联系人和沟通记录
|
||
- 客户层级树:展开/折叠正常
|
||
- 沟通记录 timeline 视图切换
|
||
- 权限控制:不同角色看到不同数据
|
||
|
||
---
|
||
|
||
## Chunk 5: Phase 3 — 高级功能 + Skill 提炼
|
||
|
||
### Task 20: 实现 graph 页面类型(AntV G6)
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/pages/PluginGraphPage.tsx`
|
||
|
||
- [ ] **Step 1: 安装 @antv/g6**
|
||
|
||
Run: `cd apps/web && pnpm add @antv/g6`
|
||
|
||
- [ ] **Step 2: 实现 graph 页面组件**
|
||
|
||
- Dynamic import 按需加载 G6
|
||
- 以选中客户为中心,展示 1 跳关系
|
||
- 点击节点继续扩展
|
||
- 节点标签用客户名称,边标签用关系类型
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/PluginGraphPage.tsx apps/web/package.json apps/web/pnpm-lock.yaml
|
||
git commit -m "feat(web): 新增 PluginGraphPage 关系图谱页面(AntV G6)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 21: 实现 dashboard 页面类型
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/pages/PluginDashboardPage.tsx`
|
||
|
||
- [ ] **Step 1: 实现 dashboard 组件**
|
||
|
||
使用 Ant Design `Statistic` / `Card` + `@ant-design/charts` 渲染统计概览。
|
||
|
||
- [ ] **Step 2: 提交**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/PluginDashboardPage.tsx
|
||
git commit -m "feat(web): 新增 PluginDashboardPage 统计概览页面"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 22: Host API 扩展 — db-count / db-aggregate
|
||
|
||
**Files:**
|
||
- Modify: `crates/erp-plugin/wit/plugin.wit` (新增接口)
|
||
- Modify: `crates/erp-plugin/src/host.rs` (实现)
|
||
- Modify: `crates/erp-plugin/src/dynamic_table.rs` (SQL 构建)
|
||
|
||
- [ ] **Step 1: 在 WIT 中新增 db-count 和 db-aggregate 接口**
|
||
|
||
- [ ] **Step 2: 实现 Host 端方法**
|
||
|
||
- [ ] **Step 3: 更新 CRM 插件 bindgen**
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add crates/erp-plugin/wit/ crates/erp-plugin/src/ crates/erp-plugin-crm/
|
||
git commit -m "feat(plugin): Host API 新增 db-count 和 db-aggregate"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 23: 提炼插件开发 Skill
|
||
|
||
**Files:**
|
||
- Create: `.claude/skills/plugin-development/SKILL.md`
|
||
|
||
- [ ] **Step 1: 编写 skill 文档**
|
||
|
||
基于 CRM 开发经验,提炼以下内容:
|
||
1. 插件开发流程指南(需求 → manifest → Rust → 编译 → 上传 → 测试)
|
||
2. 各页面类型的 manifest 配置模板
|
||
3. Rust 插件脚手架(init/on_tenant_created/handle_event 模板)
|
||
4. 可用页面类型清单(crud/detail/tree/timeline/tabs/graph/dashboard)
|
||
5. 可用字段属性清单(searchable/filterable/sortable/visible_when/unique)
|
||
6. 权限声明指南
|
||
7. 测试检查清单
|
||
8. 常见问题与陷阱
|
||
|
||
- [ ] **Step 2: 提交**
|
||
|
||
```bash
|
||
git add .claude/skills/plugin-development/
|
||
git commit -m "docs: 提炼 CRM 插件开发经验为可复用 skill"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 24: Phase 3 端到端验证 + 最终提交
|
||
|
||
- [ ] **Step 1: 全量测试**
|
||
|
||
Run: `cargo test --workspace`
|
||
|
||
- [ ] **Step 2: 手动验证关系图谱**
|
||
|
||
- 打开客户关系页面
|
||
- 以某客户为中心展示关系
|
||
- 点击节点扩展
|
||
- 筛选关系类型
|
||
|
||
- [ ] **Step 3: 手动验证统计概览**
|
||
|
||
- 客户数量统计
|
||
- 分类分布
|
||
- 等级分布
|
||
|
||
- [ ] **Step 4: 最终提交**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat(crm): CRM 客户管理插件 Phase 3 完成 — 关系图谱 + 统计概览 + Host API 扩展"
|
||
```
|
||
|
||
---
|
||
|
||
## 文件结构总览
|
||
|
||
### Phase 1 新增/修改文件
|
||
|
||
```
|
||
crates/erp-plugin/src/
|
||
dynamic_table.rs — Bug 修复 + filtered query + GIN 索引
|
||
service.rs — 权限注册/清理
|
||
manifest.rs — PluginField/PluginPage/PluginSection 扩展
|
||
data_service.rs — filter/search/sort 集成 + 数据校验
|
||
data_handler.rs — 动态权限检查 + 过滤参数
|
||
data_dto.rs — PluginDataListParams 扩展
|
||
host.rs — (Phase 1 不改,仅标记待实现)
|
||
dto.rs — (如需)
|
||
|
||
apps/web/src/
|
||
api/pluginData.ts — listPluginData 参数扩展
|
||
api/plugins.ts — schema 类型定义
|
||
pages/PluginCRUDPage.tsx — 搜索/筛选/排序/visible_when
|
||
pages/PluginDetailPage.tsx — 新增 Drawer 详情页
|
||
```
|
||
|
||
### Phase 2 新增/修改文件
|
||
|
||
```
|
||
crates/erp-plugin-crm/ — 新增 crate
|
||
Cargo.toml
|
||
src/lib.rs
|
||
plugin.toml
|
||
|
||
apps/web/src/
|
||
pages/PluginTabsPage.tsx — 新增
|
||
pages/PluginTreePage.tsx — 新增
|
||
stores/plugin.ts — 菜单生成逻辑改造
|
||
layouts/MainLayout.tsx — 动态图标/分组
|
||
App.tsx — 路由分发改造
|
||
```
|
||
|
||
### Phase 3 新增/修改文件
|
||
|
||
```
|
||
crates/erp-plugin/
|
||
wit/plugin.wit — 新增 db-count/db-aggregate
|
||
src/host.rs — 实现
|
||
src/dynamic_table.rs — 聚合 SQL
|
||
|
||
apps/web/src/
|
||
pages/PluginGraphPage.tsx — 新增
|
||
pages/PluginDashboardPage.tsx — 新增
|
||
|
||
.claude/skills/plugin-development/
|
||
SKILL.md — 插件开发 skill
|
||
```
|