Files
erp/docs/superpowers/plans/2026-04-16-crm-plugin-plan.md
iven 841766b168
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix(用户管理): 修复用户列表页面加载失败问题
修复用户列表页面加载失败导致测试超时的问题,确保页面元素正确渲染
2026-04-19 08:46:28 +08:00

1603 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```