Compare commits

...

22 Commits

Author SHA1 Message Date
iven
3b0b78c4cb docs: 强化闭环工作法 — 验证通过才能提交,提交后必须推送
§3.3 闭环工作法改为 6 步:理解→实现→验证→提交→文档同步→推送
§13 反模式新增 3 条:禁止跳过验证、禁止不推送、禁止忘记更新文档
2026-04-17 10:19:35 +08:00
iven
2616e83ec6 feat(plugin): Keyset Pagination — cursor 编解码 + 游标分页 SQL 2026-04-17 10:18:43 +08:00
iven
20734330a6 feat(plugin): SQL 查询路由 — Generated Column 字段优先使用 _f_ 前缀列 2026-04-17 10:16:35 +08:00
iven
a897cd7a87 feat(plugin): create_table 使用 Generated Column + pg_trgm + 覆盖索引 2026-04-17 10:15:05 +08:00
iven
32dd0f72c1 feat(plugin): PluginFieldType 添加 Generated Column 类型映射 2026-04-17 10:12:52 +08:00
iven
67bdf9e942 feat(db): 添加 pg_trgm 扩展 + plugin_entity_columns 元数据表
- 启用 pg_trgm 扩展加速 ILIKE '%keyword%' 模糊搜索
- 新增 plugin_entity_columns 表,记录插件动态表中哪些字段被提取为 Generated Column
- 添加 plugin_entity_id 外键关联 plugin_entities 表
- down 方法仅删表不卸载 pg_trgm(其他功能可能依赖)
2026-04-17 10:08:09 +08:00
iven
a7cf44cd46 docs: CRM 插件基座升级实施计划 — 4 Chunk 36 Task
Chunk 1: JSONB 存储优化 (Generated Column + pg_trgm + Keyset + Schema 缓存)
Chunk 2: 数据完整性框架 (ref_entity + 级联删除 + 字段校验 + 循环检测)
Chunk 3: 行级数据权限 (data_scope + TenantContext 扩展 + fallback 收紧)
Chunk 4: 前端页面能力增强 (entity_select + kanban + 批量操作 + 图表)
2026-04-17 09:57:58 +08:00
iven
d07e476898 docs(spec): 新增 CRM 插件基座升级设计规格 v1.1
6 专家组深度评审后的基座优先改进方案:
- JSONB 存储优化 (Generated Column + pg_trgm + Keyset Pagination)
- 数据完整性框架 (ref_entity + 级联策略 + 字段校验 + 循环引用检测)
- 行级数据权限 (self/department/department_tree/all 四级)
- 前端页面能力增强 (entity_select + kanban + 批量操作 + Dashboard 图表)
2026-04-17 03:13:07 +08:00
iven
2866ffb634 feat(crm): 新增关系图谱和统计概览页面 + UI/UX 全面优化
后端:
- manifest.rs 新增 Graph 和 Dashboard 页面类型到 PluginPageType 枚举
- 添加 graph 页面验证逻辑(entity/relationship_entity/source_field/target_field)

CRM 插件:
- plugin.toml 新增关系图谱页面(graph 类型,基于 customer_relationship 实体)
- plugin.toml 新增统计概览页面(dashboard 类型)
- 侧边栏菜单从 5 项扩展到 7 项

前端 — 关系图谱 (PluginGraphPage):
- 渐变节点 + 曲线箭头连线 + 关系类型色彩区分
- 鼠标悬停高亮 + Canvas Tooltip + 点击设为中心节点
- 2-hop 邻居视图 + 统计卡片(客户总数/关系总数/当前中心)
- 关系类型图例(可点击筛选)+ 暗色主题适配
- ResizeObserver 自适应 + requestAnimationFrame 动画循环

前端 — 统计概览 (PluginDashboardPage):
- 5 实体统计卡片(渐变色条 + 图标 + 数字动画)
- 可筛选字段分布卡片(Progress 进度条 + Tag 标签)
- 响应式栅格布局 + 骨架屏加载态 + 错误状态持久展示
2026-04-17 01:28:19 +08:00
iven
b08e8b5ab5 perf: 前端 API 并行化 + 后端 Redis 连接缓存 — 响应时间从 2.26s 降至 2ms
后端:
- rate_limit 中间件新增 RedisAvailability 缓存
- Redis 不可用时跳过限流,30 秒冷却后再重试
- 避免 get_multiplexed_async_connection 每次请求阻塞 2 秒

前端:
- plugin store schema 加载改为 Promise.allSettled 并行(原为 for...of 顺序)
- 先基于 entities 渲染回退菜单,schema 加载完成后更新
- 移除 Home useEffect 中 unreadCount 依赖,消除双重 fetch
- MainLayout 使用选择性 store selector 减少重渲染
2026-04-17 01:12:17 +08:00
iven
f4dd228a67 feat(web): 插件侧边栏改为三级菜单结构 — 按插件名分组可折叠
插件菜单从扁平列表改为三级结构:
  插件(分组)→ 插件名(可折叠子标题)→ 页面列表

- store 新增 PluginMenuGroup 类型和 pluginMenuGroups getter
- MainLayout 新增 SidebarSubMenu 组件,支持展开/收起
- 折叠侧边栏时子菜单显示插件图标 + tooltip
- 子菜单项增加缩进样式区分层级
- CRM 插件 name 改为 "CRM" 避免与页面标题重名
2026-04-17 01:01:19 +08:00
iven
ae62e2ecb2 feat(web): 完善插件前端页面 — 数据 API、筛选、视图切换和统计展示
- 新增 pluginData API 层:count/aggregate/stats 端点调用
- PluginCRUDPage 支持 visible_when 条件字段、筛选器下拉、视图切换
- PluginTabsPage 支持 tabs 布局和子实体 CRUD
- PluginTreePage 实现树形数据加载和节点展开/收起
- PluginGraphPage 实现关系图谱可视化展示
- PluginDashboardPage 实现统计卡片和聚合数据展示
- PluginAdmin 状态显示优化
- plugin store 增强 schema 加载逻辑和菜单生成
2026-04-16 23:42:57 +08:00
iven
3483395f5e fix(plugin): 修复插件 schema API、动态表 JSONB 和 SQL 注入防护
- get_schema 端点同时返回 entities 和 ui 页面配置,修复前端无法生成动态菜单的问题
- 动态表 INSERT/UPDATE 添加 ::jsonb 类型转换,修复 PostgreSQL 类型推断错误
- JSONB 索引创建改为非致命(warn 跳过),避免索引冲突阻断安装流程
- 权限注册/注销改用参数化查询,消除 SQL 注入风险
- DDL 语句改用 execute_unprepared,避免不必要的安全检查开销
- clear_plugin 支持已上传状态的清理
- 添加关键步骤 tracing 日志便于排查安装问题
2026-04-16 23:42:40 +08:00
iven
b482230a07 docs(crm): 更新架构快照 + 提炼插件开发 Skill
- CLAUDE.md §12 新增 CRM 插件完成记录和 erp-plugin-crm 模块
- §13 新增动态表 SQL 注入防护和插件权限注册反模式
- §10 scope 表补充 plugin/crm 范围
- §11 设计文档索引补充 CRM 插件设计和实施计划
- 新建 .claude/skills/plugin-development/SKILL.md 可复用插件开发流程
2026-04-16 19:23:54 +08:00
iven
9effa9f942 feat(plugin): 新增数据统计 REST API — count 和 aggregate 端点
- dynamic_table: 新增 build_filtered_count_sql(带过滤/搜索的 COUNT)和 build_aggregate_sql(按字段分组计数)
- data_service: 新增 count 和 aggregate 方法,支持实时统计查询
- data_handler: 新增 count_plugin_data 和 aggregate_plugin_data REST handler
- data_dto: 新增 AggregateItem、AggregateQueryParams、CountQueryParams 类型
- module: 注册 /plugins/{plugin_id}/{entity}/count 和 /aggregate 路由
- 包含 8 个新增单元测试,全部通过
2026-04-16 16:22:33 +08:00
iven
169e6d1fe5 feat(web): 新增 PluginGraphPage 关系图谱和 PluginDashboardPage 统计概览
- PluginGraphPage: Canvas 2D 绘制客户关系图谱,支持中心节点选择和关系类型筛选
- PluginDashboardPage: 全量数据前端聚合统计,支持按 filterable 字段分组计数
- App.tsx: 注册 /graph/:entityName 和 /dashboard 路由
2026-04-16 16:15:32 +08:00
iven
a6d3a0efcc feat(plugin): 实现插件权限注册,install 时写入 permissions 表、uninstall 时软删除
跨 crate 方案:erp-plugin 使用 raw SQL 操作 permissions 表,
避免直接依赖 erp-auth entity,保持模块间松耦合。

- erp-core: 新增 PermissionDescriptor 类型和 ErpModule::permissions() 方法
- erp-plugin service.rs install(): 解析 manifest.permissions,INSERT ON CONFLICT DO NOTHING
- erp-plugin service.rs uninstall(): 软删除 role_permissions 关联 + permissions 记录
2026-04-16 12:42:13 +08:00
iven
92789e6713 feat(crm): 创建 CRM 插件 crate + 前端 tabs/tree 页面类型 + 动态菜单
- CRM WASM 插件:Cargo.toml + src/lib.rs + plugin.toml(5 实体 + 9 权限 + 6 页面)
- 注册 erp-plugin-crm 到 workspace members
- PluginTabsPage: 通用标签页容器,递归渲染子页面
- PluginTreePage: 通用树形页面,前端构建树结构
- App.tsx: 新增 /tabs/:pageLabel 和 /tree/:entityName 路由
- plugin store: 从 manifest pages 生成菜单(支持 tabs 聚合)
- MainLayout: 动态图标映射(team/user/message/tags/apartment)
2026-04-16 12:41:17 +08:00
iven
e68fe8c1b1 feat(web): 插件前端全面增强 — 搜索/筛选/排序/详情页/条件表单/timeline 视图
- pluginData API: 支持 filter/search/sort_by/sort_order 参数
- plugins API: 新增 PluginFieldSchema/PluginEntitySchema/PluginPageSchema 类型
- PluginCRUDPage: 添加搜索框、筛选栏、视图切换(表格/时间线)
- PluginCRUDPage: 添加详情 Drawer(Descriptions + 嵌套 CRUD)
- PluginCRUDPage: 支持 visible_when 条件表单字段动态显示/隐藏
- PluginCRUDPage: 支持 compact 模式用于 detail 页面内嵌
2026-04-16 12:35:24 +08:00
iven
0ad77693f4 feat(plugin): 集成过滤查询/排序/搜索到 REST API,添加数据校验和 searchable 索引
- data_dto: PluginDataListParams 新增 filter/sort_by/sort_order
- data_service: list 方法支持 filter/search/sort 参数,自动提取 searchable 字段
- data_service: create/update 添加 required 字段校验
- data_service: 新增 resolve_entity_fields 和 validate_data 辅助函数
- data_handler: 权限检查从硬编码改为动态计算 plugin_id.entity.action
- dynamic_table: searchable 字段自动创建 B-tree 索引
2026-04-16 12:31:53 +08:00
iven
472bf244d8 feat(plugin): 扩展 manifest schema 支持 searchable/filterable/visible_when 和 tagged enum 页面类型
- PluginField 新增 searchable/filterable/sortable/visible_when 字段
- PluginPage 替换为 tagged enum PluginPageType(crud/tree/detail/tabs)
- 新增 PluginSection enum(fields/crud 区段)
- 新增 validate_pages 递归验证页面配置
- 更新现有测试适配新 TOML 格式
- 新增 3 个测试覆盖新页面类型解析和验证
2026-04-16 12:28:55 +08:00
iven
52c8821ffa fix(plugin): 修复唯一索引使用 CREATE UNIQUE INDEX 并添加过滤查询 SQL 构建器
- unique 字段索引从 CREATE INDEX 改为 CREATE UNIQUE INDEX
- 新增 build_unique_index_sql 辅助方法
- 新增 build_filtered_query_sql 支持 filter/search/sort 组合查询
- 新增 sanitize_identifier 防止 SQL 注入
- 添加 6 个单元测试覆盖过滤查询场景
2026-04-16 12:24:42 +08:00
36 changed files with 10112 additions and 236 deletions

View File

@@ -0,0 +1,185 @@
# 插件开发 Skill
基于 CRM 客户管理插件的开发经验,提炼可复用的插件开发流程和模式。
## 触发场景
- 用户说"开发一个新插件"、"新建行业模块"、"创建插件"
- 用户提到需要在 ERP 平台上扩展新的业务模块
## 插件开发流程
### 第一步:需求分析 → 数据模型
1. 确定插件 ID`erp-crm``erp-inventory`
2. 列出实体及其字段,为每个字段标注:
- `field_type`: String/Integer/Float/Boolean/Date/DateTime/Uuid/Decimal/Json
- `required` / `unique` / `searchable` / `filterable` / `sortable`
- `visible_when`: 条件显示表达式(如 `type == 'enterprise'`
- `ui_widget`: 表单控件input/select/textarea/datepicker
- `options`: select 类型的选项列表
### 第二步:编写 plugin.toml manifest
```toml
[metadata]
id = "erp-xxx"
name = "模块名称"
version = "0.1.0"
description = "描述"
author = "ERP Team"
min_platform_version = "0.1.0"
# 权限:{entity}.{list|manage}
[[permissions]]
code = "entity.list"
name = "查看 XX"
[[permissions]]
code = "entity.manage"
name = "管理 XX"
# 实体定义
[[schema.entities]]
name = "entity"
display_name = "实体名"
[[schema.entities.fields]]
name = "field_name"
field_type = "String"
required = true
display_name = "字段名"
searchable = true
# 页面声明
[[ui.pages]]
type = "crud"
entity = "entity"
label = "页面标题"
icon = "icon-name"
enable_search = true
```
### 第三步:创建 Rust crate
```bash
mkdir -p crates/erp-plugin-xxx/src
```
**Cargo.toml**
```toml
[package]
name = "erp-plugin-xxx"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.55"
serde = { workspace = true }
serde_json = { workspace = true }
```
**src/lib.rs**
```rust
wit_bindgen::generate!({
path: "../erp-plugin-prototype/wit/plugin.wit",
world: "plugin-world",
});
use crate::exports::erp::plugin::plugin_api::Guest;
struct XxxPlugin;
impl Guest for XxxPlugin {
fn init() -> Result<(), String> { Ok(()) }
fn on_tenant_created(_tenant_id: String) -> Result<(), String> { Ok(()) }
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> { Ok(()) }
}
export!(XxxPlugin);
```
### 第四步:注册到 workspace
`Cargo.toml``[workspace] members` 添加 `"crates/erp-plugin-xxx"`
### 第五步:编译和转换
```bash
cargo build -p erp-plugin-xxx --target wasm32-unknown-unknown --release
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_xxx.wasm -o target/erp_plugin_xxx.component.wasm
```
### 第六步:上传和测试
PluginAdmin 页面上传 `.component.wasm` + `plugin.toml`:上传 → 安装 → 启用。
## 可用页面类型
| 类型 | 说明 | 必填配置 |
|------|------|----------|
| `crud` | 增删改查表格 | `entity`, `label` |
| `tree` | 树形展示 | `entity`, `label`, `id_field`, `parent_field`, `label_field` |
| `detail` | 详情 Drawer | `entity`, `label`, `sections` |
| `tabs` | 标签页容器 | `label`, `tabs`(子页面列表) |
## detail section 类型
| 类型 | 说明 | 配置 |
|------|------|------|
| `fields` | 字段描述列表 | `label`, `fields`(字段名数组) |
| `crud` | 嵌套 CRUD 表格 | `label`, `entity`, `filter_field` |
## 字段属性速查
| 属性 | 说明 |
|------|------|
| `searchable` | 可搜索,自动创建 B-tree 索引 |
| `filterable` | 可筛选,前端渲染 Select |
| `sortable` | 可排序,表格列头排序图标 |
| `visible_when` | 条件显示,格式 `field == 'value'` |
| `unique` | 唯一约束CREATE UNIQUE INDEX |
| `ui_widget` | 控件select / textarea |
| `options` | select 选项 `[{label, value}]` |
## 权限规则
- 格式:`{entity}.{list|manage}`
- 安装时自动加 manifest_id 前缀
- REST API 动态检查,无精细权限时回退 `plugin.list` / `plugin.admin`
## REST API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/plugins/{id}/{entity}` | 列表filter/search/sort |
| POST | `/api/v1/plugins/{id}/{entity}` | 创建required 校验) |
| GET | `/api/v1/plugins/{id}/{entity}/{rid}` | 详情 |
| PUT | `/api/v1/plugins/{id}/{entity}/{rid}` | 更新(乐观锁) |
| DELETE | `/api/v1/plugins/{id}/{entity}/{rid}` | 软删除 |
| GET | `/api/v1/plugins/{id}/{entity}/count` | 统计 |
| GET | `/api/v1/plugins/{id}/{entity}/aggregate` | 聚合 |
## 测试检查清单
- [ ] `cargo check --workspace` 通过
- [ ] `cargo test --workspace` 通过
- [ ] WASM 编译 + Component 转换成功
- [ ] 上传 → 安装 → 启用流程正常
- [ ] CRUD 完整可用
- [ ] 唯一字段重复插入返回冲突
- [ ] filter/search/sort 查询正常
- [ ] visible_when 条件字段动态显示
- [ ] 侧边栏菜单正确生成
## 常见陷阱
1. 表名格式:`plugin_{sanitized_id}_{sanitized_entity}`,连字符变下划线
2. edition 必须是 "2024"
3. WIT 路径:`../erp-plugin-prototype/wit/plugin.wit`,不是 `erp-plugin`
4. JSONB 无外键约束Uuid 字段不自动校验引用完整性
5. Fuel 限制 1000 万,简单逻辑足够,避免重计算循环
6. manifest 中只写 `entity.action`,安装时自动加 manifest_id 前缀

View File

@@ -129,11 +129,18 @@ erp-server (→ 所有 crate组装入口)
1. **理解需求** — 确认改动的目标模块和影响范围
2. **最小实现** — 只改必要的代码,保持模块边界
3. **自动验证**`cargo check` / `cargo test` / `pnpm dev` 必须通过
4. **提交** — 按 §10 规范提交
5. **文档同步** — 更新相关文档(如果涉及架构变化
3. **验证通过**必须全部通过才可继续:
- `cargo check` — 编译无错误
- `cargo test --workspace` — 所有测试通过(有相关测试时
- `pnpm dev` — 前端页面可正常渲染(涉及前端时)
- 功能验证 — 启动服务实际测试改动是否生效(涉及 API 或 UI 时)
4. **提交** — 验证通过后按 §10 规范提交
5. **文档同步** — 更新相关文档(如果涉及架构、接口、模块变化)
6. **推送到仓库** — 提交后立即 `git push`,确保远程仓库同步
**铁律:步骤 4 是任务完成的硬性条件。不允许"等一下再提交"。**
**铁律:**
- **步骤 3 验证不通过 = 任务未完成**,不允许跳过验证直接提交。
- **步骤 6 推送是强制环节**,不推送就等于没完成。不允许"等一下再推"。
---
@@ -413,7 +420,8 @@ cargo test -p erp-plugin-prototype # 运行插件集成测试
| `message` | erp-message |
| `config` | erp-config |
| `server` | erp-server |
| `plugin` | erp-plugin-prototype / erp-plugin-test-sample |
| `plugin` | erp-plugin / erp-plugin-prototype / erp-plugin-test-sample |
| `crm` | erp-plugin-crm |
| `web` | Web 前端 |
| `ui` | React 组件 |
| `db` | 数据库迁移 |
@@ -439,6 +447,8 @@ chore(docker): 添加 PostgreSQL 健康检查
| `docs/superpowers/plans/2026-04-10-erp-platform-base-plan.md` | 平台底座实施计划 |
| `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | WASM 插件系统设计规格 |
| `docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md` | WASM 插件原型验证计划 |
| `docs/superpowers/specs/2026-04-16-crm-plugin-design.md` | CRM 客户管理插件设计规格 |
| `docs/superpowers/plans/2026-04-16-crm-plugin-plan.md` | CRM 插件实施计划 |
所有设计决策以设计规格文档为准。实施计划按阶段拆分,每阶段开始前细化。
@@ -461,6 +471,7 @@ chore(docker): 添加 PostgreSQL 健康检查
| Phase 6 | 整合与打磨 | ✅ 完成 |
| - | WASM 插件原型 (V1-V6) | ✅ 验证通过 |
| - | 插件系统集成到主服务 | ✅ 已集成 |
| - | CRM 插件 (Phase 1-3) | ✅ 完成 |
### 已实现模块
@@ -476,6 +487,7 @@ chore(docker): 添加 PostgreSQL 健康检查
| erp-plugin | 插件管理 (WASM 运行时/生命周期/动态表/数据CRUD) | ✅ 已集成 |
| erp-plugin-prototype | WASM 插件 Host 运行时 (Wasmtime + bindgen + Host API) | ✅ 原型验证 |
| erp-plugin-test-sample | WASM 测试插件 (Guest trait + Host API 回调) | ✅ 原型验证 |
| erp-plugin-crm | CRM 客户管理插件 (5 实体/9 权限/6 页面) | ✅ 完成 |
<!-- ARCH-SNAPSHOT-END -->
@@ -494,6 +506,11 @@ chore(docker): 添加 PostgreSQL 健康检查
- ❌ **不要**假设只有单租户 — 从第一天就按多租户设计
- ❌ **不要**提前实现远期功能 — 严格按 Phase 计划推进
- ❌ **不要**忽略 `version` 字段 — 所有更新操作必须检查乐观锁
- ❌ **不要**在动态表 SQL 中拼接用户输入 — 使用 `sanitize_identifier` 防注入
- ❌ **不要**在插件 crate 中直接依赖 erp-auth — 权限注册用 raw SQL保持模块边界
- ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步
- ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档
### 场景化指令

9
Cargo.lock generated
View File

@@ -1251,6 +1251,15 @@ dependencies = [
"wasmtime-wasi",
]
[[package]]
name = "erp-plugin-crm"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"wit-bindgen 0.55.0",
]
[[package]]
name = "erp-plugin-prototype"
version = "0.1.0"

View File

@@ -11,6 +11,7 @@ members = [
"crates/erp-plugin-prototype",
"crates/erp-plugin-test-sample",
"crates/erp-plugin",
"crates/erp-plugin-crm",
]
[workspace.package]

View File

@@ -16,6 +16,10 @@ const Messages = lazy(() => import('./pages/Messages'));
const Settings = lazy(() => import('./pages/Settings'));
const PluginAdmin = lazy(() => import('./pages/PluginAdmin'));
const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage'));
const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => ({ default: m.PluginTabsPage })));
const PluginTreePage = lazy(() => import('./pages/PluginTreePage').then((m) => ({ default: m.PluginTreePage })));
const PluginGraphPage = lazy(() => import('./pages/PluginGraphPage').then((m) => ({ default: m.PluginGraphPage })));
const PluginDashboardPage = lazy(() => import('./pages/PluginDashboardPage').then((m) => ({ default: m.PluginDashboardPage })));
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
@@ -138,6 +142,10 @@ export default function App() {
<Route path="/messages" element={<Messages />} />
<Route path="/settings" element={<Settings />} />
<Route path="/plugins/admin" element={<PluginAdmin />} />
<Route path="/plugins/:pluginId/tabs/:pageLabel" element={<PluginTabsPage />} />
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
<Route path="/plugins/:pluginId/graph/:entityName" element={<PluginGraphPage />} />
<Route path="/plugins/:pluginId/dashboard" element={<PluginDashboardPage />} />
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
</Routes>
</Suspense>

View File

@@ -16,15 +16,32 @@ interface PaginatedDataResponse {
total_pages: number;
}
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 }>(
`/plugins/${pluginId}/${entity}`,
{ params: { page, page_size: pageSize } },
{ params },
);
return data.data;
}
@@ -69,3 +86,40 @@ export async function deletePluginData(
) {
await client.delete(`/plugins/${pluginId}/${entity}/${id}`);
}
export async function countPluginData(
pluginId: string,
entity: string,
options?: { filter?: Record<string, string>; search?: string },
) {
const params: Record<string, string> = {};
if (options?.filter) params.filter = JSON.stringify(options.filter);
if (options?.search) params.search = options.search;
const { data } = await client.get<{ success: boolean; data: number }>(
`/plugins/${pluginId}/${entity}/count`,
{ params },
);
return data.data;
}
export interface AggregateItem {
key: string;
count: number;
}
export async function aggregatePluginData(
pluginId: string,
entity: string,
groupBy: string,
filter?: Record<string, string>,
) {
const params: Record<string, string> = { group_by: groupBy };
if (filter) params.filter = JSON.stringify(filter);
const { data } = await client.get<{ success: boolean; data: AggregateItem[] }>(
`/plugins/${pluginId}/${entity}/aggregate`,
{ params },
);
return data.data;
}

View File

@@ -113,9 +113,52 @@ export async function updatePluginConfig(id: string, config: Record<string, unkn
return data.data;
}
export async function getPluginSchema(id: string) {
const { data } = await client.get<{ success: boolean; data: Record<string, unknown> }>(
export async function getPluginSchema(id: string): Promise<PluginSchemaResponse> {
const { data } = await client.get<{ success: boolean; data: PluginSchemaResponse }>(
`/admin/plugins/${id}/schema`,
);
return data.data;
}
// ── Schema 类型定义 ──
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[];
ui?: PluginUiSchema;
}
export interface PluginUiSchema {
pages: PluginPageSchema[];
}
export type PluginPageSchema =
| { type: 'crud'; entity: string; label: string; icon?: string; enable_search?: boolean; enable_views?: string[] }
| { type: 'tree'; entity: string; label: string; icon?: string; id_field: string; parent_field: string; label_field: string }
| { type: 'detail'; entity: string; label: string; sections: PluginSectionSchema[] }
| { type: 'tabs'; label: string; icon?: string; tabs: PluginPageSchema[] }
| { type: 'graph'; entity: string; label: string; relationship_entity: string; source_field: string; target_field: string; edge_label_field: string; node_label_field: string }
| { type: 'dashboard'; label: string };
export type PluginSectionSchema =
| { type: 'fields'; label: string; fields: string[] }
| { type: 'crud'; label: string; entity: string; filter_field?: string; enable_views?: string[] };

View File

@@ -658,6 +658,50 @@ body {
margin-left: 12px;
}
/* Sidebar sub-menu (plugin group) */
.erp-sidebar-submenu-title {
display: flex;
align-items: center;
height: 32px;
margin: 6px 8px 2px 8px;
padding: 0 12px;
border-radius: 6px;
cursor: pointer;
color: #94A3B8;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.15s;
user-select: none;
}
.erp-sidebar-submenu-title:hover {
background: rgba(255, 255, 255, 0.06);
color: #E2E8F0;
}
.erp-sidebar-submenu-title-active {
color: #A5B4FC;
}
.erp-sidebar-submenu-arrow {
display: flex;
align-items: center;
margin-right: 8px;
font-size: 10px;
}
.erp-sidebar-submenu-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.erp-sidebar-item-indented {
padding-left: 36px;
}
/* Main layout */
.erp-main-layout {
transition: margin-left 0.2s cubic-bezier(0.4, 0, 0.2, 1);

View File

@@ -1,4 +1,4 @@
import { useCallback, memo, useEffect } from 'react';
import { useCallback, useState, memo, useEffect } from 'react';
import { Layout, Avatar, Space, Dropdown, Tooltip, theme } from 'antd';
import {
HomeOutlined,
@@ -15,11 +15,16 @@ import {
BulbOutlined,
BulbFilled,
AppstoreOutlined,
TeamOutlined,
TableOutlined,
TagsOutlined,
RightOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppStore } from '../stores/app';
import { useAuthStore } from '../stores/auth';
import { usePluginStore } from '../stores/plugin';
import type { PluginMenuGroup } from '../stores/plugin';
import NotificationPanel from '../components/NotificationPanel';
const { Header, Sider, Content, Footer } = Layout;
@@ -64,17 +69,19 @@ const SidebarMenuItem = memo(function SidebarMenuItem({
isActive,
collapsed,
onClick,
indented,
}: {
item: MenuItem;
isActive: boolean;
collapsed: boolean;
onClick: () => void;
indented?: boolean;
}) {
return (
<Tooltip title={collapsed ? item.label : ''} placement="right">
<div
onClick={onClick}
className={`erp-sidebar-item ${isActive ? 'erp-sidebar-item-active' : ''}`}
className={`erp-sidebar-item ${isActive ? 'erp-sidebar-item-active' : ''} ${indented ? 'erp-sidebar-item-indented' : ''}`}
>
<span className="erp-sidebar-item-icon">{item.icon}</span>
{!collapsed && <span className="erp-sidebar-item-label">{item.label}</span>}
@@ -83,10 +90,97 @@ const SidebarMenuItem = memo(function SidebarMenuItem({
);
});
// 动态图标映射
const pluginIconMap: Record<string, React.ReactNode> = {
AppstoreOutlined: <AppstoreOutlined />,
team: <TeamOutlined />,
TeamOutlined: <TeamOutlined />,
user: <UserOutlined />,
UserOutlined: <UserOutlined />,
message: <MessageOutlined />,
MessageOutlined: <MessageOutlined />,
tags: <TagsOutlined />,
TagsOutlined: <TagsOutlined />,
apartment: <ApartmentOutlined />,
ApartmentOutlined: <ApartmentOutlined />,
TableOutlined: <TableOutlined />,
DashboardOutlined: <AppstoreOutlined />,
};
function getPluginIcon(iconName: string): React.ReactNode {
return pluginIconMap[iconName] || <AppstoreOutlined />;
}
// 插件子菜单组 — 可折叠二级标题 + 三级菜单项
const SidebarSubMenu = memo(function SidebarSubMenu({
group,
collapsed,
currentPath,
onNavigate,
}: {
group: PluginMenuGroup;
collapsed: boolean;
currentPath: string;
onNavigate: (key: string) => void;
}) {
const [expanded, setExpanded] = useState(true);
const hasActive = group.items.some((item) => currentPath === item.key);
if (collapsed) {
// 折叠模式显示插件图标Tooltip 列出所有子项
const tooltipContent = group.items.map((item) => item.label).join(' / ');
return (
<Tooltip title={tooltipContent} placement="right">
<div
onClick={() => {
const first = group.items[0];
if (first) onNavigate(first.key);
}}
className={`erp-sidebar-item ${hasActive ? 'erp-sidebar-item-active' : ''}`}
>
<span className="erp-sidebar-item-icon"><AppstoreOutlined /></span>
</div>
</Tooltip>
);
}
return (
<div>
<div
className={`erp-sidebar-submenu-title ${hasActive ? 'erp-sidebar-submenu-title-active' : ''}`}
onClick={() => setExpanded((e) => !e)}
>
<span className="erp-sidebar-submenu-arrow">
<RightOutlined
style={{ fontSize: 10, transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
/>
</span>
<span className="erp-sidebar-submenu-label">{group.pluginName}</span>
</div>
{expanded && group.items.map((item) => (
<SidebarMenuItem
key={item.key}
item={{
key: item.key,
icon: getPluginIcon(item.icon),
label: item.label,
}}
isActive={currentPath === item.key}
collapsed={collapsed}
onClick={() => onNavigate(item.key)}
indented
/>
))}
</div>
);
});
export default function MainLayout({ children }: { children: React.ReactNode }) {
const { sidebarCollapsed, toggleSidebar, theme: themeMode, setTheme } = useAppStore();
const { user, logout } = useAuthStore();
const { pluginMenuItems, fetchPlugins } = usePluginStore();
const pluginMenuItems = usePluginStore((s) => s.pluginMenuItems);
const pluginMenuGroups = usePluginStore((s) => s.pluginMenuGroups);
const fetchPlugins = usePluginStore((s) => s.fetchPlugins);
theme.useToken();
const navigate = useNavigate();
const location = useLocation();
@@ -170,21 +264,17 @@ export default function MainLayout({ children }: { children: React.ReactNode })
</div>
{/* 菜单组:插件 */}
{pluginMenuItems.length > 0 && (
{pluginMenuGroups.length > 0 && (
<>
{!sidebarCollapsed && <div className="erp-sidebar-group"></div>}
<div className="erp-sidebar-menu">
{pluginMenuItems.map((item) => (
<SidebarMenuItem
key={item.key}
item={{
key: item.key,
icon: <AppstoreOutlined />,
label: item.label,
}}
isActive={currentPath === item.key}
{pluginMenuGroups.map((group) => (
<SidebarSubMenu
key={group.pluginId}
group={group}
collapsed={sidebarCollapsed}
onClick={() => navigate(item.key)}
currentPath={currentPath}
onNavigate={(key) => navigate(key)}
/>
))}
</div>

View File

@@ -147,7 +147,7 @@ export default function Home() {
loadStats();
return () => { cancelled = true; };
}, [fetchUnreadCount, unreadCount]);
}, [fetchUnreadCount]);
const handleNavigate = useCallback((path: string) => {
navigate(path);

View File

@@ -21,7 +21,6 @@ import {
CloudDownloadOutlined,
DeleteOutlined,
ReloadOutlined,
AppstoreOutlined,
HeartOutlined,
} from '@ant-design/icons';
import type { PluginInfo, PluginStatus } from '../api/plugins';
@@ -267,7 +266,7 @@ export default function PluginAdmin() {
}}
maxCount={1}
accept=".wasm"
fileList={wasmFile ? [wasmFile as unknown as Parameters<typeof Upload>[0]] : []}
fileList={[]}
onRemove={() => setWasmFile(null)}
>
<Button icon={<UploadOutlined />}> WASM </Button>

View File

@@ -14,81 +14,215 @@ import {
Tag,
message,
Popconfirm,
Drawer,
Descriptions,
Segmented,
Timeline,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
ReloadOutlined,
EyeOutlined,
} from '@ant-design/icons';
import {
listPluginData,
createPluginData,
updatePluginData,
deletePluginData,
type PluginDataListOptions,
} from '../api/pluginData';
import { getPluginSchema } from '../api/plugins';
import {
getPluginSchema,
type PluginFieldSchema,
type PluginEntitySchema,
type PluginPageSchema,
type PluginSectionSchema,
} from '../api/plugins';
interface FieldDef {
name: string;
field_type: string;
required: boolean;
display_name?: string;
ui_widget?: string;
options?: { label: string; value: string }[];
const { Search } = Input;
const { TextArea } = Input;
/** visible_when 表达式解析 */
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] };
}
interface EntitySchema {
name: string;
display_name: string;
fields: FieldDef[];
/** 判断字段是否应该显示 */
function shouldShowField(
allValues: Record<string, unknown>,
visibleWhen: string | undefined,
): boolean {
if (!visibleWhen) return true;
const parsed = parseVisibleWhen(visibleWhen);
if (!parsed) return true;
return String(allValues[parsed.field] ?? '') === parsed.value;
}
export default function PluginCRUDPage() {
const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>();
interface PluginCRUDPageProps {
/** 如果从 tabs/detail 页面内嵌使用,通过 props 传入配置 */
pluginIdOverride?: string;
entityOverride?: string;
filterField?: string;
filterValue?: string;
enableViews?: string[];
/** detail 页面内嵌时使用 compact 模式 */
compact?: boolean;
}
export default function PluginCRUDPage({
pluginIdOverride,
entityOverride,
filterField,
filterValue,
enableViews: enableViewsProp,
compact,
}: PluginCRUDPageProps = {}) {
const routeParams = useParams<{ pluginId: string; entityName: string }>();
const pluginId = pluginIdOverride || routeParams.pluginId || '';
const entityName = entityOverride || routeParams.entityName || '';
const [records, setRecords] = useState<Record<string, unknown>[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [fields, setFields] = useState<FieldDef[]>([]);
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
const [displayName, setDisplayName] = useState(entityName || '');
const [modalOpen, setModalOpen] = useState(false);
const [editRecord, setEditRecord] = useState<Record<string, unknown> | null>(null);
const [form] = Form.useForm();
const [formValues, setFormValues] = useState<Record<string, unknown>>({});
// 筛选/搜索/排序 state
const [searchText, setSearchText] = useState('');
const [filters, setFilters] = useState<Record<string, string>>({});
const [sortBy, setSortBy] = useState<string | undefined>();
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// 视图切换
const [viewMode, setViewMode] = useState<string>('table');
// 详情 Drawer
const [detailOpen, setDetailOpen] = useState(false);
const [detailRecord, setDetailRecord] = useState<Record<string, unknown> | null>(null);
const [detailSections, setDetailSections] = useState<PluginSectionSchema[]>([]);
const [allEntities, setAllEntities] = useState<PluginEntitySchema[]>([]);
const [allPages, setAllPages] = useState<PluginPageSchema[]>([]);
// 从 fields 中提取 filterable 字段
const filterableFields = fields.filter((f) => f.filterable);
// 查找是否有 detail 页面
const hasDetailPage = allPages.some(
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
);
// 可用视图
const enableViews = enableViewsProp ||
(() => {
const page = allPages.find(
(p) => p.type === 'crud' && 'entity' in p && p.entity === entityName,
);
return (page as { enable_views?: string[] })?.enable_views || ['table'];
})();
// 加载 schema
useEffect(() => {
if (!pluginId) return;
getPluginSchema(pluginId)
.then((schema) => {
const entities = (schema as { entities?: EntitySchema[] }).entities || [];
const abortController = new AbortController();
async function loadSchema() {
try {
const schema = await getPluginSchema(pluginId!);
if (abortController.signal.aborted) return;
const entities: PluginEntitySchema[] = (schema as { entities?: PluginEntitySchema[] }).entities || [];
setAllEntities(entities);
const entity = entities.find((e) => e.name === entityName);
if (entity) {
setFields(entity.fields);
setDisplayName(entity.display_name || entityName || '');
}
})
.catch(() => {
// schema 加载失败时仍可使用
});
const ui = (schema as { ui?: { pages: PluginPageSchema[] } }).ui;
if (ui?.pages) {
setAllPages(ui.pages);
const detailPage = ui.pages.find(
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
);
if (detailPage && 'sections' in detailPage) {
setDetailSections(detailPage.sections);
}
}
} catch {
message.warning('Schema 加载失败,部分功能不可用');
}
}
loadSchema();
return () => abortController.abort();
}, [pluginId, entityName]);
const fetchData = useCallback(async (p = page) => {
if (!pluginId || !entityName) return;
setLoading(true);
try {
const result = await listPluginData(pluginId, entityName, p);
setRecords(result.data.map((r) => ({ ...r.data, _id: r.id, _version: r.version })));
setTotal(result.total);
} catch {
message.error('加载数据失败');
}
setLoading(false);
}, [pluginId, entityName, page]);
const fetchData = useCallback(
async (p = page, overrides?: { search?: string; sort_by?: string; sort_order?: 'asc' | 'desc' }) => {
if (!pluginId || !entityName) return;
setLoading(true);
try {
const options: PluginDataListOptions = {};
const mergedFilters = { ...filters };
if (filterField && filterValue) {
mergedFilters[filterField] = filterValue;
}
if (Object.keys(mergedFilters).length > 0) {
options.filter = mergedFilters;
}
const effectiveSearch = overrides?.search ?? searchText;
if (effectiveSearch) options.search = effectiveSearch;
const effectiveSortBy = overrides?.sort_by ?? sortBy;
const effectiveSortOrder = overrides?.sort_order ?? sortOrder;
if (effectiveSortBy) {
options.sort_by = effectiveSortBy;
options.sort_order = effectiveSortOrder;
}
const result = await listPluginData(pluginId, entityName, p, 20, options);
setRecords(
result.data.map((r) => ({ ...r.data, _id: r.id, _version: r.version })),
);
setTotal(result.total);
} catch {
message.error('加载数据失败');
}
setLoading(false);
},
[pluginId, entityName, page, filters, searchText, sortBy, sortOrder, filterField, filterValue],
);
useEffect(() => {
fetchData();
}, [fetchData]);
// 筛选变化
const handleFilterChange = (fieldName: string, value: string | undefined) => {
const newFilters = { ...filters };
if (value) {
newFilters[fieldName] = value;
} else {
delete newFilters[fieldName];
}
setFilters(newFilters);
setPage(1);
// 直接触发重新查询
fetchData(1);
};
const handleSubmit = async (values: Record<string, unknown>) => {
if (!pluginId || !entityName) return;
// 去除内部字段
const { _id, _version, ...data } = values as Record<string, unknown> & { _id?: string; _version?: number };
const { _id, _version, ...data } = values as Record<string, unknown> & {
_id?: string;
_version?: number;
};
try {
if (editRecord) {
@@ -130,6 +264,7 @@ export default function PluginCRUDPage() {
dataIndex: f.name,
key: f.name,
ellipsis: true,
sorter: f.sortable ? true : undefined,
render: (val: unknown) => {
if (typeof val === 'boolean') return val ? <Tag color="green"></Tag> : <Tag></Tag>;
return String(val ?? '-');
@@ -138,15 +273,28 @@ export default function PluginCRUDPage() {
{
title: '操作',
key: 'action',
width: 150,
width: hasDetailPage ? 200 : 150,
render: (_: unknown, record: Record<string, unknown>) => (
<Space size="small">
{hasDetailPage && (
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => {
setDetailRecord(record);
setDetailOpen(true);
}}
>
</Button>
)}
<Button
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditRecord(record);
form.setFieldsValue(record);
setFormValues(record);
setModalOpen(true);
}}
>
@@ -163,7 +311,7 @@ export default function PluginCRUDPage() {
];
// 动态生成表单字段
const renderFormField = (field: FieldDef) => {
const renderFormField = (field: PluginFieldSchema) => {
const widget = field.ui_widget || field.field_type;
switch (widget) {
case 'number':
@@ -186,71 +334,284 @@ export default function PluginCRUDPage() {
))}
</Select>
);
case 'textarea':
return <TextArea rows={3} />;
default:
return <Input />;
}
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0 }}>{displayName}</h2>
<Space>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => {
setEditRecord(null);
form.resetFields();
setModalOpen(true);
}}
>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>
</Button>
</Space>
</div>
// Timeline 视图渲染
const renderTimeline = () => {
const dateField = fields.find((f) => f.field_type === 'DateTime' || f.field_type === 'date');
const titleField = fields.find((f) => f.searchable)?.name || fields[1]?.name;
const contentField = fields.find((f) => f.ui_widget === 'textarea')?.name;
<Table
columns={columns}
dataSource={records}
rowKey="_id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => setPage(p),
showTotal: (t) => `${t}`,
}}
return (
<Timeline
items={records.map((record) => ({
children: (
<div>
{titleField && (
<p>
<strong>{String(record[titleField] ?? '-')}</strong>
</p>
)}
{contentField && <p>{String(record[contentField] ?? '-')}</p>}
{dateField && (
<p style={{ color: '#999', fontSize: 12 }}>
{String(record[dateField.name] ?? '-')}
</p>
)}
</div>
),
}))}
/>
);
};
// 详情 Drawer 渲染
const renderDetailDrawer = () => {
if (!detailRecord) return null;
return (
<Drawer
title={displayName + ' 详情'}
open={detailOpen}
onClose={() => {
setDetailOpen(false);
setDetailRecord(null);
}}
width={640}
>
{detailSections.length > 0 ? (
detailSections.map((section, idx) => {
if (section.type === 'fields') {
return (
<div key={idx} style={{ marginBottom: 24 }}>
<h4>{section.label}</h4>
<Descriptions column={2} bordered size="small">
{section.fields.map((fieldName) => {
const fieldDef = fields.find((f) => f.name === fieldName);
const val = detailRecord[fieldName];
return (
<Descriptions.Item
key={fieldName}
label={fieldDef?.display_name || fieldName}
>
{typeof val === 'boolean' ? (
val ? (
<Tag color="green"></Tag>
) : (
<Tag></Tag>
)
) : (
String(val ?? '-')
)}
</Descriptions.Item>
);
})}
</Descriptions>
</div>
);
}
if (section.type === 'crud') {
const secEntity = allEntities.find((e) => e.name === section.entity);
return (
<div key={idx} style={{ marginBottom: 24 }}>
<h4>{section.label}</h4>
{secEntity && (
<PluginCRUDPage
pluginIdOverride={pluginId}
entityOverride={section.entity}
filterField={section.filter_field}
filterValue={String(detailRecord._id ?? '')}
enableViews={section.enable_views}
compact
/>
)}
</div>
);
}
return null;
})
) : (
// 没有 sections 配置时,默认展示所有字段
<Descriptions column={2} bordered size="small">
{fields.map((field) => {
const val = detailRecord[field.name];
return (
<Descriptions.Item key={field.name} label={field.display_name || field.name}>
{typeof val === 'boolean' ? (
val ? (
<Tag color="green"></Tag>
) : (
<Tag></Tag>
)
) : (
String(val ?? '-')
)}
</Descriptions.Item>
);
})}
</Descriptions>
)}
</Drawer>
);
};
return (
<div style={compact ? { padding: 0 } : { padding: 24 }}>
{!compact && (
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h2 style={{ margin: 0 }}>{displayName}</h2>
<Space>
{enableViews.length > 1 && (
<Segmented
options={enableViews.map((v) => ({
label: v === 'table' ? '表格' : v === 'timeline' ? '时间线' : v,
value: v,
}))}
value={viewMode}
onChange={(val) => setViewMode(val as string)}
/>
)}
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => {
setEditRecord(null);
form.resetFields();
setFormValues({});
setModalOpen(true);
}}
>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>
</Button>
</Space>
</div>
)}
{/* 搜索和筛选栏 */}
{!compact && (
<Space style={{ marginBottom: 16 }} wrap>
{fields.some((f) => f.searchable) && (
<Search
placeholder="搜索..."
allowClear
style={{ width: 240 }}
onSearch={(value) => {
setSearchText(value);
setPage(1);
fetchData(1, { search: value });
}}
/>
)}
{filterableFields.map((field) => (
<Select
key={field.name}
placeholder={field.display_name || field.name}
allowClear
style={{ width: 150 }}
options={field.options || []}
onChange={(value) => handleFilterChange(field.name, value)}
/>
))}
</Space>
)}
{viewMode === 'table' || enableViews.length <= 1 ? (
<Table
columns={columns}
dataSource={records}
rowKey="_id"
loading={loading}
size={compact ? 'small' : undefined}
onChange={(_pagination, _filters, sorter) => {
if (!Array.isArray(sorter) && sorter.field) {
const newSortBy = String(sorter.field);
const newSortOrder = sorter.order === 'ascend' ? 'asc' as const : 'desc' as const;
setSortBy(newSortBy);
setSortOrder(newSortOrder);
setPage(1);
fetchData(1, { sort_by: newSortBy, sort_order: newSortOrder });
} else if (!sorter || (Array.isArray(sorter) && sorter.length === 0)) {
setSortBy(undefined);
setSortOrder('desc');
setPage(1);
fetchData(1, { sort_by: undefined, sort_order: undefined });
}
}}
pagination={
compact
? { pageSize: 5, showTotal: (t) => `${t}` }
: {
current: page,
total,
pageSize: 20,
onChange: (p) => setPage(p),
showTotal: (t) => `${t}`,
}
}
/>
) : viewMode === 'timeline' ? (
renderTimeline()
) : null}
{/* 新增/编辑弹窗 */}
<Modal
title={editRecord ? '编辑' : '新增'}
open={modalOpen}
onCancel={() => {
setModalOpen(false);
setEditRecord(null);
setFormValues({});
}}
onOk={() => form.submit()}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
{fields.map((field) => (
<Form.Item
key={field.name}
name={field.name}
label={field.display_name || field.name}
rules={field.required ? [{ required: true, message: `请输入${field.display_name || field.name}` }] : []}
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
>
{renderFormField(field)}
</Form.Item>
))}
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
onValuesChange={(_, allValues) => setFormValues(allValues)}
>
{fields.map((field) => {
// visible_when 条件显示
const visible = shouldShowField(formValues, field.visible_when);
if (!visible) return null;
return (
<Form.Item
key={field.name}
name={field.name}
label={field.display_name || field.name}
rules={
field.required
? [{ required: true, message: `请输入${field.display_name || field.name}` }]
: []
}
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
>
{renderFormField(field)}
</Form.Item>
);
})}
</Form>
</Modal>
{/* 详情 Drawer */}
{renderDetailDrawer()}
</div>
);
}

View File

@@ -0,0 +1,646 @@
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Row, Col, Spin, Empty, Select, Tag, Progress, Skeleton, theme, Tooltip } from 'antd';
import {
TeamOutlined,
PhoneOutlined,
TagsOutlined,
RiseOutlined,
DashboardOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import { countPluginData, aggregatePluginData, type AggregateItem } from '../api/pluginData';
import { getPluginSchema, type PluginEntitySchema, type PluginSchemaResponse } from '../api/plugins';
// ── 类型定义 ──
interface EntityStat {
name: string;
displayName: string;
count: number;
icon: React.ReactNode;
gradient: string;
iconBg: string;
}
interface FieldBreakdown {
fieldName: string;
displayName: string;
items: AggregateItem[];
}
// ── 色板配置 ──
const ENTITY_PALETTE: Record<string, { gradient: string; iconBg: string; tagColor: string }> = {
customer: {
gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)',
iconBg: 'rgba(79, 70, 229, 0.12)',
tagColor: 'purple',
},
contact: {
gradient: 'linear-gradient(135deg, #059669, #10B981)',
iconBg: 'rgba(5, 150, 105, 0.12)',
tagColor: 'green',
},
communication: {
gradient: 'linear-gradient(135deg, #D97706, #F59E0B)',
iconBg: 'rgba(217, 119, 6, 0.12)',
tagColor: 'orange',
},
customer_tag: {
gradient: 'linear-gradient(135deg, #7C3AED, #A78BFA)',
iconBg: 'rgba(124, 58, 237, 0.12)',
tagColor: 'volcano',
},
customer_relationship: {
gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)',
iconBg: 'rgba(225, 29, 72, 0.12)',
tagColor: 'red',
},
};
const DEFAULT_PALETTE = {
gradient: 'linear-gradient(135deg, #2563EB, #3B82F6)',
iconBg: 'rgba(37, 99, 235, 0.12)',
tagColor: 'blue',
};
const TAG_COLORS = [
'blue', 'green', 'orange', 'red', 'purple', 'cyan',
'magenta', 'gold', 'lime', 'geekblue', 'volcano',
];
// ── 图标映射 ──
const ENTITY_ICONS: Record<string, React.ReactNode> = {
customer: <TeamOutlined />,
contact: <TeamOutlined />,
communication: <PhoneOutlined />,
customer_tag: <TagsOutlined />,
customer_relationship: <RiseOutlined />,
};
// ── 计数动画 Hook ──
function useCountUp(end: number, duration = 800) {
const [count, setCount] = useState(0);
const prevEnd = useRef(end);
useEffect(() => {
if (end === prevEnd.current && count > 0) return;
prevEnd.current = end;
if (end === 0) {
setCount(0);
return;
}
const startTime = performance.now();
function tick(now: number) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setCount(Math.round(end * eased));
if (progress < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}, [end, duration]);
return count;
}
// ── 子组件 ──
function StatValue({ value, loading }: { value: number; loading: boolean }) {
const animatedValue = useCountUp(value);
if (loading) return <Spin size="small" />;
return <span className="erp-count-up">{animatedValue.toLocaleString()}</span>;
}
/** 顶部统计卡片 */
function StatCard({
stat,
loading,
delay,
}: {
stat: EntityStat;
loading: boolean;
delay: string;
}) {
return (
<Col xs={12} sm={8} lg={Math.max(4, 24 / 5)} key={stat.name}>
<div
className={`erp-stat-card ${delay}`}
style={
{
'--card-gradient': stat.gradient,
'--card-icon-bg': stat.iconBg,
} as React.CSSProperties
}
>
<div className="erp-stat-card-bar" />
<div className="erp-stat-card-body">
<div className="erp-stat-card-info">
<div className="erp-stat-card-title">{stat.displayName}</div>
<div className="erp-stat-card-value">
<StatValue value={stat.count} loading={loading} />
</div>
</div>
<div className="erp-stat-card-icon">{stat.icon}</div>
</div>
</div>
</Col>
);
}
/** 骨架屏卡片 */
function SkeletonStatCard({ delay }: { delay: string }) {
return (
<Col xs={12} sm={8} lg={Math.max(4, 24 / 5)}>
<div className={`erp-stat-card ${delay}`}>
<div className="erp-stat-card-bar" style={{ opacity: 0.3 }} />
<div className="erp-stat-card-body">
<div className="erp-stat-card-info">
<div style={{ width: 80, height: 14, marginBottom: 12 }}>
<Skeleton.Input active size="small" style={{ width: 80, height: 14 }} />
</div>
<div style={{ width: 60, height: 32 }}>
<Skeleton.Input active style={{ width: 60, height: 32 }} />
</div>
</div>
<div style={{ width: 48, height: 48 }}>
<Skeleton.Avatar active shape="square" size={48} />
</div>
</div>
</div>
</Col>
);
}
/** 字段分布卡片 */
function BreakdownCard({
breakdown,
totalCount,
palette,
index,
}: {
breakdown: FieldBreakdown;
totalCount: number;
palette: { tagColor: string };
index: number;
}) {
const maxCount = Math.max(...breakdown.items.map((i) => i.count), 1);
return (
<Col xs={24} sm={12} lg={8} key={breakdown.fieldName}>
<div
className={`erp-content-card erp-fade-in`}
style={{ animationDelay: `${0.05 * index}s`, opacity: 0 }}
>
<div className="erp-section-header" style={{ marginBottom: 16 }}>
<InfoCircleOutlined className="erp-section-icon" style={{ fontSize: 14 }} />
<span
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--erp-text-primary)',
}}
>
{breakdown.displayName}
</span>
<span
style={{
marginLeft: 'auto',
fontSize: 12,
color: 'var(--erp-text-tertiary)',
}}
>
{breakdown.items.length}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{breakdown.items.map((item, idx) => {
const percent = totalCount > 0 ? Math.round((item.count / totalCount) * 100) : 0;
const barPercent = maxCount > 0 ? Math.round((item.count / maxCount) * 100) : 0;
const color = TAG_COLORS[idx % TAG_COLORS.length];
return (
<div key={`${breakdown.fieldName}-${item.key}-${idx}`}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
}}
>
<Tooltip title={`${item.key}: ${item.count} (${percent}%)`}>
<Tag
color={color}
style={{
margin: 0,
maxWidth: '60%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.key || '(空)'}
</Tag>
</Tooltip>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--erp-text-primary)',
fontVariantNumeric: 'tabular-nums',
}}
>
{item.count}
<span
style={{
fontSize: 11,
fontWeight: 400,
color: 'var(--erp-text-tertiary)',
marginLeft: 4,
}}
>
{percent}%
</span>
</span>
</div>
<Progress
percent={barPercent}
showInfo={false}
strokeColor={color === 'blue' ? '#3B82F6'
: color === 'green' ? '#10B981'
: color === 'orange' ? '#F59E0B'
: color === 'red' ? '#EF4444'
: color === 'purple' ? '#8B5CF6'
: color === 'cyan' ? '#06B6D4'
: color === 'magenta' ? '#EC4899'
: color === 'gold' ? '#EAB308'
: color === 'lime' ? '#84CC16'
: color === 'geekblue' ? '#6366F1'
: color === 'volcano' ? '#F97316'
: '#3B82F6'}
trailColor="var(--erp-border-light)"
size="small"
style={{ marginBottom: 0 }}
/>
</div>
);
})}
</div>
{breakdown.items.length === 0 && (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无数据"
style={{ padding: '12px 0' }}
/>
)}
</div>
</Col>
);
}
/** 骨架屏分布卡片 */
function SkeletonBreakdownCard({ index }: { index: number }) {
return (
<Col xs={24} sm={12} lg={8}>
<div
className="erp-content-card erp-fade-in"
style={{ animationDelay: `${0.05 * index}s`, opacity: 0 }}
>
<Skeleton active paragraph={{ rows: 4 }} />
</div>
</Col>
);
}
// ── 延迟类名工具 ──
const DELAY_CLASSES = [
'erp-fade-in erp-fade-in-delay-1',
'erp-fade-in erp-fade-in-delay-2',
'erp-fade-in erp-fade-in-delay-3',
'erp-fade-in erp-fade-in-delay-4',
'erp-fade-in erp-fade-in-delay-4',
];
function getDelayClass(index: number): string {
return DELAY_CLASSES[index % DELAY_CLASSES.length];
}
// ── 主组件 ──
export function PluginDashboardPage() {
const { pluginId } = useParams<{ pluginId: string }>();
const { token: themeToken } = theme.useToken();
const [loading, setLoading] = useState(false);
const [schemaLoading, setSchemaLoading] = useState(false);
const [entities, setEntities] = useState<PluginEntitySchema[]>([]);
const [selectedEntity, setSelectedEntity] = useState<string>('');
const [entityStats, setEntityStats] = useState<EntityStat[]>([]);
const [breakdowns, setBreakdowns] = useState<FieldBreakdown[]>([]);
const [error, setError] = useState<string | null>(null);
const isDark =
themeToken.colorBgContainer === '#111827' ||
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
// 加载 schema
useEffect(() => {
if (!pluginId) return;
const abortController = new AbortController();
async function loadSchema() {
setSchemaLoading(true);
setError(null);
try {
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
if (abortController.signal.aborted) return;
const entityList = schema.entities || [];
setEntities(entityList);
if (entityList.length > 0) {
setSelectedEntity(entityList[0].name);
}
} catch {
setError('Schema 加载失败,部分功能不可用');
} finally {
if (!abortController.signal.aborted) setSchemaLoading(false);
}
}
loadSchema();
return () => abortController.abort();
}, [pluginId]);
const currentEntity = useMemo(
() => entities.find((e) => e.name === selectedEntity),
[entities, selectedEntity],
);
const filterableFields = useMemo(
() => currentEntity?.fields.filter((f) => f.filterable) || [],
[currentEntity],
);
// 加载所有实体的计数
useEffect(() => {
if (!pluginId || entities.length === 0) return;
const abortController = new AbortController();
async function loadAllCounts() {
const results: EntityStat[] = [];
for (const entity of entities) {
if (abortController.signal.aborted) return;
try {
const count = await countPluginData(pluginId!, entity.name);
if (abortController.signal.aborted) return;
const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE;
results.push({
name: entity.name,
displayName: entity.display_name || entity.name,
count,
icon: ENTITY_ICONS[entity.name] || <DashboardOutlined />,
gradient: palette.gradient,
iconBg: palette.iconBg,
});
} catch {
const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE;
results.push({
name: entity.name,
displayName: entity.display_name || entity.name,
count: 0,
icon: ENTITY_ICONS[entity.name] || <DashboardOutlined />,
gradient: palette.gradient,
iconBg: palette.iconBg,
});
}
}
if (!abortController.signal.aborted) {
setEntityStats(results);
}
}
loadAllCounts();
return () => abortController.abort();
}, [pluginId, entities]);
// 当前实体的聚合数据
const loadData = useCallback(async () => {
if (!pluginId || !selectedEntity || filterableFields.length === 0) return;
const abortController = new AbortController();
setLoading(true);
setError(null);
try {
const totalCount = entityStats.find((s) => s.name === selectedEntity)?.count ?? 0;
const fieldResults: FieldBreakdown[] = [];
for (const field of filterableFields) {
if (abortController.signal.aborted) return;
try {
const items = await aggregatePluginData(pluginId!, selectedEntity!, field.name);
if (abortController.signal.aborted) return;
fieldResults.push({
fieldName: field.name,
displayName: field.display_name || field.name,
items,
});
} catch {
// 单个字段聚合失败不影响其他字段
}
}
if (!abortController.signal.aborted) {
setBreakdowns(fieldResults);
}
} catch {
setError('统计数据加载失败');
} finally {
if (!abortController.signal.aborted) setLoading(false);
}
return () => abortController.abort();
}, [pluginId, selectedEntity, filterableFields, entityStats]);
useEffect(() => {
const cleanup = loadData();
return () => {
cleanup?.then((fn) => fn?.()).catch(() => {});
};
}, [loadData]);
// 当前选中实体的总数
const currentTotal = useMemo(
() => entityStats.find((s) => s.name === selectedEntity)?.count ?? 0,
[entityStats, selectedEntity],
);
// 当前实体的色板
const currentPalette = useMemo(
() => ENTITY_PALETTE[selectedEntity] || DEFAULT_PALETTE,
[selectedEntity],
);
// ── 渲染 ──
if (schemaLoading) {
return (
<div style={{ padding: 24 }}>
<Row gutter={[16, 16]}>
{Array.from({ length: 5 }).map((_, i) => (
<SkeletonStatCard key={i} delay={getDelayClass(i)} />
))}
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 24 }}>
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonBreakdownCard key={i} index={i} />
))}
</Row>
</div>
);
}
return (
<div style={{ padding: 24 }}>
{/* 页面标题 */}
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<h2
style={{
fontSize: 24,
fontWeight: 700,
color: isDark ? '#F1F5F9' : '#0F172A',
margin: '0 0 4px',
letterSpacing: '-0.5px',
}}
>
</h2>
<p
style={{
fontSize: 14,
color: isDark ? '#94A3B8' : '#475569',
margin: 0,
}}
>
CRM
</p>
</div>
<Select
value={selectedEntity || undefined}
style={{ width: 160 }}
options={entities.map((e) => ({
label: e.display_name || e.name,
value: e.name,
}))}
onChange={setSelectedEntity}
aria-label="选择实体类型"
/>
</div>
</div>
{/* 顶部统计卡片 */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{loading && entityStats.length === 0
? Array.from({ length: 5 }).map((_, i) => (
<SkeletonStatCard key={i} delay={getDelayClass(i)} />
))
: entityStats.map((stat, i) => (
<StatCard
key={stat.name}
stat={stat}
loading={loading}
delay={getDelayClass(i)}
/>
))}
</Row>
{/* 分组统计区域 */}
<div style={{ marginBottom: 16 }}>
<div className="erp-section-header">
<DashboardOutlined
className="erp-section-icon"
style={{ color: currentPalette.tagColor === 'purple' ? '#4F46E5' : '#3B82F6' }}
/>
<span className="erp-section-title">
{currentEntity?.display_name || selectedEntity}
</span>
<span
style={{
marginLeft: 'auto',
fontSize: 12,
color: 'var(--erp-text-tertiary)',
}}
>
{currentTotal.toLocaleString()}
</span>
</div>
</div>
{loading && breakdowns.length === 0 ? (
<Row gutter={[16, 16]}>
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonBreakdownCard key={i} index={i} />
))}
</Row>
) : breakdowns.length > 0 ? (
<Row gutter={[16, 16]}>
{breakdowns.map((bd, i) => (
<BreakdownCard
key={bd.fieldName}
breakdown={bd}
totalCount={currentTotal}
palette={currentPalette}
index={i}
/>
))}
</Row>
) : (
<div className="erp-content-card" style={{ textAlign: 'center', padding: '48px 24px' }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
filterableFields.length === 0
? '当前实体无可筛选项,暂无分布数据'
: '暂无数据'
}
/>
</div>
)}
{/* 错误提示 */}
{error && (
<div
style={{
marginTop: 16,
padding: '12px 16px',
borderRadius: 8,
background: isDark ? 'rgba(220, 38, 38, 0.1)' : '#FEF2F2',
color: isDark ? '#FCA5A5' : '#991B1B',
fontSize: 13,
}}
role="alert"
>
{error}
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Tabs, Spin, message } from 'antd';
import { getPluginSchema, type PluginPageSchema, type PluginSchemaResponse } from '../api/plugins';
import PluginCRUDPage from './PluginCRUDPage';
import { PluginTreePage } from './PluginTreePage';
/**
* 插件 Tabs 页面 — 通过路由参数自加载 schema
* 路由: /plugins/:pluginId/tabs/:pageLabel
*/
export function PluginTabsPage() {
const { pluginId, pageLabel } = useParams<{ pluginId: string; pageLabel: string }>();
const [loading, setLoading] = useState(true);
const [tabs, setTabs] = useState<PluginPageSchema[]>([]);
const [activeKey, setActiveKey] = useState('');
useEffect(() => {
if (!pluginId || !pageLabel) return;
const abortController = new AbortController();
async function loadSchema() {
try {
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
const pages = schema.ui?.pages || [];
const tabsPage = pages.find(
(p): p is PluginPageSchema & { type: 'tabs' } =>
p.type === 'tabs' && p.label === pageLabel,
);
if (tabsPage && 'tabs' in tabsPage) {
setTabs(tabsPage.tabs);
const firstLabel = tabsPage.tabs.find((t) => 'label' in t)?.label || '';
setActiveKey(firstLabel);
}
} catch {
message.warning('Schema 加载失败,部分功能不可用');
} finally {
if (!abortController.signal.aborted) setLoading(false);
}
}
loadSchema();
return () => abortController.abort();
}, [pluginId, pageLabel]);
if (loading) {
return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>;
}
const renderTabContent = (tab: PluginPageSchema) => {
if (tab.type === 'crud') {
return (
<PluginCRUDPage
pluginIdOverride={pluginId}
entityOverride={tab.entity}
enableViews={tab.enable_views}
/>
);
}
if (tab.type === 'tree') {
return (
<PluginTreePage
pluginIdOverride={pluginId}
entityOverride={tab.entity}
/>
);
}
return <div>: {tab.type}</div>;
};
const items = tabs.map((tab) => ({
key: 'label' in tab ? tab.label : '',
label: 'label' in tab ? tab.label : '',
children: renderTabContent(tab),
}));
return (
<div style={{ padding: 24 }}>
<Tabs activeKey={activeKey} onChange={setActiveKey} items={items} />
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { useEffect, useState, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { Tree, Descriptions, Card, Empty, Spin, message } from 'antd';
import type { TreeProps } from 'antd';
import { listPluginData, type PluginDataRecord } from '../api/pluginData';
import {
getPluginSchema,
type PluginFieldSchema,
type PluginPageSchema,
type PluginSchemaResponse,
} from '../api/plugins';
interface TreeNode {
key: string;
title: string;
children: TreeNode[];
raw: Record<string, unknown>;
}
interface PluginTreePageProps {
pluginIdOverride?: string;
entityOverride?: string;
}
/**
* 插件树形页面 — 通过路由参数自加载 schema
* 路由: /plugins/:pluginId/tree/:entityName
* 也支持通过 props 覆盖(用于 tabs 内嵌)
*/
export function PluginTreePage({ pluginIdOverride, entityOverride }: PluginTreePageProps = {}) {
const routeParams = useParams<{ pluginId: string; entityName: string }>();
const pluginId = pluginIdOverride || routeParams.pluginId || '';
const entityName = entityOverride || routeParams.entityName || '';
const [records, setRecords] = useState<PluginDataRecord[]>([]);
const [loading, setLoading] = useState(false);
const [selectedNode, setSelectedNode] = useState<TreeNode | null>(null);
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
const [treeConfig, setTreeConfig] = useState<{
idField: string;
parentField: string;
labelField: string;
} | null>(null);
// 加载 schema
useEffect(() => {
if (!pluginId || !entityName) return;
const abortController = new AbortController();
async function loadSchema() {
try {
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
if (abortController.signal.aborted) return;
const entity = schema.entities?.find((e) => e.name === entityName);
if (entity) {
setFields(entity.fields);
}
const pages = schema.ui?.pages || [];
const treePage = pages.find(
(p): p is PluginPageSchema & { type: 'tree'; entity: string; id_field: string; parent_field: string; label_field: string } =>
p.type === 'tree' && p.entity === entityName,
);
if (treePage) {
setTreeConfig({
idField: treePage.id_field,
parentField: treePage.parent_field,
labelField: treePage.label_field,
});
}
} catch {
message.warning('Schema 加载失败,部分功能不可用');
}
}
loadSchema();
return () => abortController.abort();
}, [pluginId, entityName]);
// 加载数据
useEffect(() => {
if (!pluginId || !entityName) return;
const abortController = new AbortController();
async function loadAll() {
setLoading(true);
try {
let allRecords: PluginDataRecord[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
if (abortController.signal.aborted) return;
const result = await listPluginData(pluginId!, entityName!, page, 100);
allRecords = [...allRecords, ...result.data];
hasMore = result.data.length === 100 && allRecords.length < result.total;
page++;
}
if (!abortController.signal.aborted) {
setRecords(allRecords);
}
} catch {
message.warning('数据加载失败');
}
if (!abortController.signal.aborted) setLoading(false);
}
loadAll();
return () => abortController.abort();
}, [pluginId, entityName]);
const idField = treeConfig?.idField || 'id';
const parentField = treeConfig?.parentField || 'parent_id';
const labelField = treeConfig?.labelField || fields[1]?.name || 'name';
// 构建树结构
const treeData = useMemo(() => {
const nodeMap = new Map<string, TreeNode>();
const rootNodes: TreeNode[] = [];
for (const record of records) {
const data = record.data;
const key = String(data[idField] || record.id);
const title = String(data[labelField] || '未命名');
nodeMap.set(key, {
key,
title,
children: [],
raw: { ...data, _id: record.id, _version: record.version },
});
}
for (const record of records) {
const data = record.data;
const key = String(data[idField] || record.id);
const parentKey = data[parentField] ? String(data[parentField]) : null;
const node = nodeMap.get(key)!;
if (parentKey && nodeMap.has(parentKey)) {
nodeMap.get(parentKey)!.children.push(node);
} else {
rootNodes.push(node);
}
}
return rootNodes;
}, [records, idField, parentField, labelField]);
const onSelect: TreeProps['onSelect'] = (selectedKeys, info) => {
if (selectedKeys.length > 0) {
setSelectedNode(info.node as unknown as TreeNode);
} else {
setSelectedNode(null);
}
};
if (loading) {
return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>;
}
return (
<div style={{ padding: 24, display: 'flex', gap: 16 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<Card title={(entityName || '') + ' 层级'} size="small">
{treeData.length === 0 ? (
<Empty description="暂无数据" />
) : (
<Tree showLine defaultExpandAll treeData={treeData} onSelect={onSelect} />
)}
</Card>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Card title="节点详情" size="small">
{selectedNode ? (
<Descriptions column={1} bordered size="small">
{fields.map((field) => (
<Descriptions.Item key={field.name} label={field.display_name || field.name}>
{String(selectedNode.raw[field.name] ?? '-')}
</Descriptions.Item>
))}
</Descriptions>
) : (
<Empty description="点击左侧节点查看详情" />
)}
</Card>
</div>
</div>
);
}

View File

@@ -1,20 +1,28 @@
import { create } from 'zustand';
import type { PluginInfo, PluginStatus } from '../api/plugins';
import { listPlugins } from '../api/plugins';
import type { PluginInfo, PluginStatus, PluginPageSchema, PluginSchemaResponse } from '../api/plugins';
import { listPlugins, getPluginSchema } from '../api/plugins';
export interface PluginMenuItem {
key: string;
icon: string;
label: string;
pluginId: string;
entity: string;
menuGroup?: string;
entity?: string;
pageType: 'crud' | 'tree' | 'tabs' | 'detail' | 'graph' | 'dashboard';
}
export interface PluginMenuGroup {
pluginId: string;
pluginName: string;
items: PluginMenuItem[];
}
interface PluginStore {
plugins: PluginInfo[];
loading: boolean;
pluginMenuItems: PluginMenuItem[];
pluginMenuGroups: PluginMenuGroup[];
schemaCache: Record<string, PluginSchemaResponse>;
fetchPlugins: (page?: number, status?: PluginStatus) => Promise<void>;
refreshMenuItems: () => void;
}
@@ -23,12 +31,42 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
plugins: [],
loading: false,
pluginMenuItems: [],
pluginMenuGroups: [],
schemaCache: {},
fetchPlugins: async (page = 1, status?: PluginStatus) => {
set({ loading: true });
try {
const result = await listPlugins(page, 100, status);
set({ plugins: result.data });
// 先基于 entities 生成回退菜单,确保侧边栏快速渲染
get().refreshMenuItems();
// 并行加载所有运行中插件的 schema完成后更新菜单
const activePlugins = result.data.filter(
(p) => p.status === 'running' || p.status === 'enabled'
);
if (activePlugins.length === 0) return;
const entries = await Promise.allSettled(
activePlugins.map(async (plugin) => {
try {
const schema = await getPluginSchema(plugin.id) as PluginSchemaResponse;
return [plugin.id, schema] as const;
} catch {
return null;
}
})
);
const schemas: Record<string, PluginSchemaResponse> = { ...get().schemaCache };
for (const entry of entries) {
if (entry.status === 'fulfilled' && entry.value) {
schemas[entry.value[0]] = entry.value[1];
}
}
set({ schemaCache: schemas });
get().refreshMenuItems();
} finally {
set({ loading: false });
@@ -36,24 +74,96 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
},
refreshMenuItems: () => {
const { plugins } = get();
const { plugins, schemaCache } = get();
const items: PluginMenuItem[] = [];
for (const plugin of plugins) {
if (plugin.status !== 'running' && plugin.status !== 'enabled') continue;
for (const entity of plugin.entities) {
items.push({
key: `/plugins/${plugin.id}/${entity.name}`,
icon: 'AppstoreOutlined',
label: entity.display_name || entity.name,
pluginId: plugin.id,
entity: entity.name,
menuGroup: undefined,
});
const schema = schemaCache[plugin.id];
const pages = (schema as { ui?: { pages: PluginPageSchema[] } })?.ui?.pages;
if (pages && pages.length > 0) {
for (const page of pages) {
if (page.type === 'tabs') {
items.push({
key: `/plugins/${plugin.id}/tabs/${encodeURIComponent(page.label)}`,
icon: page.icon || 'AppstoreOutlined',
label: page.label,
pluginId: plugin.id,
pageType: 'tabs' as const,
});
} else if (page.type === 'tree') {
items.push({
key: `/plugins/${plugin.id}/tree/${page.entity}`,
icon: page.icon || 'ApartmentOutlined',
label: page.label,
pluginId: plugin.id,
entity: page.entity,
pageType: 'tree' as const,
});
} else if (page.type === 'crud') {
items.push({
key: `/plugins/${plugin.id}/${page.entity}`,
icon: page.icon || 'TableOutlined',
label: page.label,
pluginId: plugin.id,
entity: page.entity,
pageType: 'crud' as const,
});
} else if (page.type === 'graph') {
items.push({
key: `/plugins/${plugin.id}/graph/${page.entity}`,
icon: 'ApartmentOutlined',
label: page.label,
pluginId: plugin.id,
entity: page.entity,
pageType: 'graph' as const,
});
} else if (page.type === 'dashboard') {
items.push({
key: `/plugins/${plugin.id}/dashboard`,
icon: 'DashboardOutlined',
label: page.label,
pluginId: plugin.id,
pageType: 'dashboard' as const,
});
}
// detail 类型不生成菜单项
}
} else {
// 回退:从 entities 生成菜单
for (const entity of plugin.entities) {
items.push({
key: `/plugins/${plugin.id}/${entity.name}`,
icon: 'AppstoreOutlined',
label: entity.display_name || entity.name,
pluginId: plugin.id,
entity: entity.name,
pageType: 'crud',
});
}
}
}
set({ pluginMenuItems: items });
// 按 pluginId 分组生成三级菜单(复用上方已解构的 plugins
const groupMap = new Map<string, PluginMenuItem[]>();
for (const item of items) {
const list = groupMap.get(item.pluginId) || [];
list.push(item);
groupMap.set(item.pluginId, list);
}
const groups: PluginMenuGroup[] = [];
for (const [pluginId, groupItems] of groupMap) {
const plugin = plugins.find((p) => p.id === pluginId);
groups.push({
pluginId,
pluginName: plugin?.name || pluginId,
items: groupItems,
});
}
set({ pluginMenuGroups: groups });
},
}));

View File

@@ -8,4 +8,4 @@ pub mod rbac;
pub mod types;
// 便捷导出
pub use module::{ModuleContext, ModuleType};
pub use module::{ModuleContext, ModuleType, PermissionDescriptor};

View File

@@ -7,6 +7,22 @@ use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::events::EventBus;
/// 权限描述符,用于模块声明自己需要的权限。
///
/// 各业务模块通过 `ErpModule::permissions()` 返回此列表,
/// 由 erp-server 在启动时统一注册到权限表。
#[derive(Clone, Debug)]
pub struct PermissionDescriptor {
/// 权限编码,全局唯一,格式建议 `{模块}.{动作}` 如 `plugin.admin`
pub code: String,
/// 权限显示名称
pub name: String,
/// 权限描述
pub description: String,
/// 所属模块名称
pub module: String,
}
/// 模块类型
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModuleType {
@@ -90,6 +106,13 @@ pub trait ErpModule: Send + Sync {
Ok(())
}
/// 返回此模块需要注册的权限列表。
///
/// 默认返回空列表,有权限需求的模块(如 plugin可覆写此方法。
fn permissions(&self) -> Vec<PermissionDescriptor> {
vec![]
}
/// Downcast support: return `self` as `&dyn Any` for concrete type access.
///
/// This allows the server crate to retrieve module-specific methods

View File

@@ -0,0 +1,13 @@
[package]
name = "erp-plugin-crm"
version = "0.1.0"
edition = "2024"
description = "CRM 客户管理插件 — ERP 平台第一个行业插件"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.55"
serde = { workspace = true }
serde_json = { workspace = true }

View File

@@ -0,0 +1,425 @@
[metadata]
id = "erp-crm"
name = "CRM"
version = "0.1.0"
description = "客户关系管理插件 — ERP 平台第一个行业插件"
author = "ERP Team"
min_platform_version = "0.1.0"
# ── 权限声明 ──
[[permissions]]
code = "customer.list"
name = "查看客户"
description = "查看客户列表和详情"
[[permissions]]
code = "customer.manage"
name = "管理客户"
description = "创建、编辑、删除客户"
[[permissions]]
code = "contact.list"
name = "查看联系人"
[[permissions]]
code = "contact.manage"
name = "管理联系人"
[[permissions]]
code = "communication.list"
name = "查看沟通记录"
[[permissions]]
code = "communication.manage"
name = "管理沟通记录"
[[permissions]]
code = "tag.manage"
name = "管理客户标签"
[[permissions]]
code = "relationship.list"
name = "查看客户关系"
[[permissions]]
code = "relationship.manage"
name = "管理客户关系"
# ── 实体定义 ──
[[schema.entities]]
name = "customer"
display_name = "客户"
[[schema.entities.fields]]
name = "code"
field_type = "string"
required = true
display_name = "客户编码"
unique = true
searchable = true
[[schema.entities.fields]]
name = "name"
field_type = "string"
required = true
display_name = "客户名称"
searchable = true
[[schema.entities.fields]]
name = "customer_type"
field_type = "string"
required = true
display_name = "客户类型"
ui_widget = "select"
filterable = true
options = [
{ label = "企业", value = "enterprise" },
{ label = "个人", value = "personal" }
]
[[schema.entities.fields]]
name = "industry"
field_type = "string"
display_name = "行业"
filterable = true
[[schema.entities.fields]]
name = "region"
field_type = "string"
display_name = "地区"
filterable = true
[[schema.entities.fields]]
name = "source"
field_type = "string"
display_name = "来源"
ui_widget = "select"
options = [
{ label = "推荐", value = "referral" },
{ label = "广告", value = "ad" },
{ label = "展会", value = "exhibition" },
{ label = "主动联系", value = "outreach" },
{ label = "其他", value = "other" }
]
[[schema.entities.fields]]
name = "level"
field_type = "string"
display_name = "等级"
ui_widget = "select"
filterable = true
options = [
{ label = "潜在客户", value = "potential" },
{ label = "普通客户", value = "normal" },
{ label = "VIP", value = "vip" },
{ label = "SVIP", value = "svip" }
]
[[schema.entities.fields]]
name = "status"
field_type = "string"
required = true
display_name = "状态"
ui_widget = "select"
filterable = true
options = [
{ label = "活跃", value = "active" },
{ label = "停用", value = "inactive" },
{ label = "黑名单", value = "blacklist" }
]
[[schema.entities.fields]]
name = "credit_code"
field_type = "string"
display_name = "统一社会信用代码"
visible_when = "customer_type == 'enterprise'"
[[schema.entities.fields]]
name = "id_number"
field_type = "string"
display_name = "身份证号"
visible_when = "customer_type == 'personal'"
[[schema.entities.fields]]
name = "parent_id"
field_type = "uuid"
display_name = "上级客户"
[[schema.entities.fields]]
name = "website"
field_type = "string"
display_name = "网站"
[[schema.entities.fields]]
name = "address"
field_type = "string"
display_name = "地址"
[[schema.entities.fields]]
name = "remark"
field_type = "string"
display_name = "备注"
ui_widget = "textarea"
[[schema.entities]]
name = "contact"
display_name = "联系人"
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
required = true
display_name = "所属客户"
[[schema.entities.fields]]
name = "name"
field_type = "string"
required = true
display_name = "姓名"
searchable = true
[[schema.entities.fields]]
name = "position"
field_type = "string"
display_name = "职务"
[[schema.entities.fields]]
name = "department"
field_type = "string"
display_name = "部门"
[[schema.entities.fields]]
name = "phone"
field_type = "string"
display_name = "手机号"
[[schema.entities.fields]]
name = "email"
field_type = "string"
display_name = "邮箱"
[[schema.entities.fields]]
name = "wechat"
field_type = "string"
display_name = "微信号"
[[schema.entities.fields]]
name = "is_primary"
field_type = "boolean"
display_name = "主联系人"
[[schema.entities.fields]]
name = "remark"
field_type = "string"
display_name = "备注"
[[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 = "关联联系人"
[[schema.entities.fields]]
name = "type"
field_type = "string"
required = true
display_name = "类型"
ui_widget = "select"
filterable = true
options = [
{ label = "电话", value = "phone" },
{ label = "邮件", value = "email" },
{ label = "会议", value = "meeting" },
{ label = "拜访", value = "visit" },
{ label = "其他", value = "other" }
]
[[schema.entities.fields]]
name = "subject"
field_type = "string"
required = true
display_name = "主题"
searchable = true
[[schema.entities.fields]]
name = "content"
field_type = "string"
required = true
display_name = "内容"
ui_widget = "textarea"
[[schema.entities.fields]]
name = "occurred_at"
field_type = "date_time"
required = true
display_name = "沟通时间"
sortable = true
[[schema.entities.fields]]
name = "next_follow_up"
field_type = "date"
display_name = "下次跟进日期"
[[schema.entities]]
name = "customer_tag"
display_name = "客户标签"
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
required = true
display_name = "关联客户"
[[schema.entities.fields]]
name = "tag_name"
field_type = "string"
required = true
display_name = "标签名称"
searchable = true
[[schema.entities.fields]]
name = "tag_category"
field_type = "string"
display_name = "标签分类"
ui_widget = "select"
options = [
{ label = "行业", value = "industry" },
{ label = "地区", value = "region" },
{ label = "来源", value = "source" },
{ label = "自定义", value = "custom" }
]
[[schema.entities]]
name = "customer_relationship"
display_name = "客户关系"
[[schema.entities.fields]]
name = "from_customer_id"
field_type = "uuid"
required = true
display_name = "源客户"
[[schema.entities.fields]]
name = "to_customer_id"
field_type = "uuid"
required = true
display_name = "目标客户"
[[schema.entities.fields]]
name = "relationship_type"
field_type = "string"
required = true
display_name = "关系类型"
ui_widget = "select"
filterable = true
options = [
{ label = "母子公司", value = "parent_child" },
{ label = "兄弟公司", value = "sibling" },
{ label = "合作伙伴", value = "partner" },
{ label = "供应商", value = "supplier" },
{ label = "竞争对手", value = "competitor" }
]
[[schema.entities.fields]]
name = "description"
field_type = "string"
display_name = "关系描述"
# ── 页面声明 ──
[[ui.pages]]
type = "tabs"
label = "客户管理"
icon = "team"
[[ui.pages.tabs]]
label = "客户列表"
type = "crud"
entity = "customer"
enable_search = true
enable_views = ["table"]
[[ui.pages.tabs]]
label = "客户层级"
type = "tree"
entity = "customer"
id_field = "id"
parent_field = "parent_id"
label_field = "name"
[[ui.pages]]
type = "detail"
entity = "customer"
label = "客户详情"
[[ui.pages.sections]]
type = "fields"
label = "基本信息"
fields = ["code", "name", "customer_type", "industry", "region", "level", "status", "credit_code", "id_number", "website", "address", "remark"]
[[ui.pages.sections]]
type = "crud"
label = "联系人"
entity = "contact"
filter_field = "customer_id"
[[ui.pages.sections]]
type = "crud"
label = "沟通记录"
entity = "communication"
filter_field = "customer_id"
enable_views = ["table", "timeline"]
[[ui.pages]]
type = "crud"
entity = "contact"
label = "联系人"
icon = "user"
enable_search = true
[[ui.pages]]
type = "crud"
entity = "communication"
label = "沟通记录"
icon = "message"
enable_search = true
enable_views = ["table", "timeline"]
[[ui.pages]]
type = "crud"
entity = "customer_tag"
label = "标签管理"
icon = "tags"
[[ui.pages]]
type = "crud"
entity = "customer_relationship"
label = "客户关系"
icon = "apartment"
[[ui.pages]]
type = "graph"
entity = "customer"
label = "关系图谱"
icon = "apartment"
relationship_entity = "customer_relationship"
source_field = "from_customer_id"
target_field = "to_customer_id"
edge_label_field = "relationship_type"
node_label_field = "name"
[[ui.pages]]
type = "dashboard"
label = "统计概览"
icon = "DashboardOutlined"

View File

@@ -0,0 +1,29 @@
//! CRM 客户管理插件 — WASM Guest 实现
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> {
// CRM 插件初始化:当前无需创建默认数据
Ok(())
}
fn on_tenant_created(_tenant_id: String) -> Result<(), String> {
// 为新租户创建 CRM 默认数据:当前无需创建默认客户
Ok(())
}
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> {
// CRM V1: 无事件处理
Ok(())
}
}
export!(CrmPlugin);

View File

@@ -22,3 +22,4 @@ axum = { workspace = true }
utoipa = { workspace = true }
async-trait = { workspace = true }
sha2 = { workspace = true }
base64 = "0.22"

View File

@@ -29,5 +29,39 @@ pub struct UpdatePluginDataReq {
pub struct PluginDataListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
/// Base64 编码的游标(用于 Keyset 分页)
pub cursor: Option<String>,
pub search: Option<String>,
/// JSON 格式过滤: {"field":"value"}
pub filter: Option<String>,
pub sort_by: Option<String>,
/// "asc" or "desc"
pub sort_order: Option<String>,
}
/// 聚合查询响应项
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AggregateItem {
/// 分组键(字段值)
pub key: String,
/// 计数
pub count: i64,
}
/// 聚合查询参数
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
pub struct AggregateQueryParams {
/// 分组字段名
pub group_by: String,
/// JSON 格式过滤: {"field":"value"}
pub filter: Option<String>,
}
/// 统计查询参数
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
pub struct CountQueryParams {
/// 搜索关键词
pub search: Option<String>,
/// JSON 格式过滤: {"field":"value"}
pub filter: Option<String>,
}

View File

@@ -1,16 +1,33 @@
use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, FromQueryResult, QueryFilter, Statement};
use uuid::Uuid;
use erp_core::error::AppResult;
use erp_core::error::{AppError, AppResult};
use erp_core::events::EventBus;
use crate::data_dto::PluginDataResp;
use crate::dynamic_table::DynamicTableManager;
use crate::entity::plugin;
use crate::entity::plugin_entity;
use crate::error::PluginError;
use crate::manifest::PluginField;
pub struct PluginDataService;
/// 插件实体信息(合并查询减少 DB 调用)
struct EntityInfo {
table_name: String,
schema_json: serde_json::Value,
}
impl EntityInfo {
fn fields(&self) -> AppResult<Vec<PluginField>> {
let entity_def: crate::manifest::PluginEntity =
serde_json::from_value(self.schema_json.clone())
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
Ok(entity_def.fields)
}
}
impl PluginDataService {
/// 创建插件数据
pub async fn create(
@@ -22,9 +39,12 @@ impl PluginDataService {
db: &sea_orm::DatabaseConnection,
_event_bus: &EventBus,
) -> AppResult<PluginDataResp> {
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let fields = info.fields()?;
validate_data(&data, &fields)?;
let (sql, values) =
DynamicTableManager::build_insert_sql(&table_name, tenant_id, operator_id, &data);
DynamicTableManager::build_insert_sql(&info.table_name, tenant_id, operator_id, &data);
#[derive(FromQueryResult)]
struct InsertResult {
@@ -53,7 +73,7 @@ impl PluginDataService {
})
}
/// 列表查询
/// 列表查询(支持过滤/搜索/排序)
pub async fn list(
plugin_id: Uuid,
entity_name: &str,
@@ -61,11 +81,30 @@ impl PluginDataService {
page: u64,
page_size: u64,
db: &sea_orm::DatabaseConnection,
filter: Option<serde_json::Value>,
search: Option<String>,
sort_by: Option<String>,
sort_order: Option<String>,
) -> AppResult<(Vec<PluginDataResp>, u64)> {
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
// 获取 searchable 字段列表
let entity_fields = info.fields()?;
let search_tuple = {
let searchable: Vec<&str> = entity_fields
.iter()
.filter(|f| f.searchable == Some(true))
.map(|f| f.name.as_str())
.collect();
match (searchable.is_empty(), &search) {
(false, Some(kw)) => Some((searchable.join(","), kw.clone())),
_ => None,
}
};
// Count
let (count_sql, count_values) = DynamicTableManager::build_count_sql(&table_name, tenant_id);
let (count_sql, count_values) =
DynamicTableManager::build_count_sql(&info.table_name, tenant_id);
#[derive(FromQueryResult)]
struct CountResult {
count: i64,
@@ -81,8 +120,18 @@ impl PluginDataService {
.unwrap_or(0);
// Query
let offset = (page.saturating_sub(1)) * page_size;
let (sql, values) = DynamicTableManager::build_query_sql(&table_name, tenant_id, page_size, offset);
let offset = page.saturating_sub(1) * page_size;
let (sql, values) = DynamicTableManager::build_filtered_query_sql(
&info.table_name,
tenant_id,
page_size,
offset,
filter,
search_tuple,
sort_by,
sort_order,
)
.map_err(|e| AppError::Validation(e))?;
#[derive(FromQueryResult)]
struct DataRow {
@@ -123,8 +172,8 @@ impl PluginDataService {
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AppResult<PluginDataResp> {
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
let (sql, values) = DynamicTableManager::build_get_by_id_sql(&table_name, id, tenant_id);
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let (sql, values) = DynamicTableManager::build_get_by_id_sql(&info.table_name, id, tenant_id);
#[derive(FromQueryResult)]
struct DataRow {
@@ -142,7 +191,7 @@ impl PluginDataService {
))
.one(db)
.await?
.ok_or_else(|| erp_core::error::AppError::NotFound("记录不存在".to_string()))?;
.ok_or_else(|| AppError::NotFound("记录不存在".to_string()))?;
Ok(PluginDataResp {
id: row.id.to_string(),
@@ -165,9 +214,12 @@ impl PluginDataService {
db: &sea_orm::DatabaseConnection,
_event_bus: &EventBus,
) -> AppResult<PluginDataResp> {
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let fields = info.fields()?;
validate_data(&data, &fields)?;
let (sql, values) = DynamicTableManager::build_update_sql(
&table_name,
&info.table_name,
id,
tenant_id,
operator_id,
@@ -191,7 +243,7 @@ impl PluginDataService {
))
.one(db)
.await?
.ok_or_else(|| erp_core::error::AppError::VersionMismatch)?;
.ok_or_else(|| AppError::VersionMismatch)?;
Ok(PluginDataResp {
id: result.id.to_string(),
@@ -211,8 +263,8 @@ impl PluginDataService {
db: &sea_orm::DatabaseConnection,
_event_bus: &EventBus,
) -> AppResult<()> {
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
let (sql, values) = DynamicTableManager::build_delete_sql(&table_name, id, tenant_id);
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let (sql, values) = DynamicTableManager::build_delete_sql(&info.table_name, id, tenant_id);
db.execute(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
@@ -223,15 +275,128 @@ impl PluginDataService {
Ok(())
}
/// 统计记录数(支持过滤和搜索)
pub async fn count(
plugin_id: Uuid,
entity_name: &str,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
filter: Option<serde_json::Value>,
search: Option<String>,
) -> AppResult<u64> {
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let entity_fields = info.fields()?;
let search_tuple = {
let searchable: Vec<&str> = entity_fields
.iter()
.filter(|f| f.searchable == Some(true))
.map(|f| f.name.as_str())
.collect();
match (searchable.is_empty(), &search) {
(false, Some(kw)) => Some((searchable.join(","), kw.clone())),
_ => None,
}
};
let (sql, values) = DynamicTableManager::build_filtered_count_sql(
&info.table_name,
tenant_id,
filter,
search_tuple,
)
.map_err(|e| AppError::Validation(e))?;
#[derive(FromQueryResult)]
struct CountResult {
count: i64,
}
let result = CountResult::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
values,
))
.one(db)
.await?
.map(|r| r.count as u64)
.unwrap_or(0);
Ok(result)
}
/// 聚合查询 — 按字段分组计数
/// 返回 [(分组键, 计数), ...]
pub async fn aggregate(
plugin_id: Uuid,
entity_name: &str,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
group_by_field: &str,
filter: Option<serde_json::Value>,
) -> AppResult<Vec<(String, i64)>> {
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let (sql, values) = DynamicTableManager::build_aggregate_sql(
&info.table_name,
tenant_id,
group_by_field,
filter,
)
.map_err(|e| AppError::Validation(e))?;
#[derive(FromQueryResult)]
struct AggRow {
key: Option<String>,
count: i64,
}
let rows = AggRow::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
values,
))
.all(db)
.await?;
let result = rows
.into_iter()
.map(|r| (r.key.unwrap_or_default(), r.count))
.collect();
Ok(result)
}
}
/// 从 plugin_entities 表解析 table_name带租户隔离
async fn resolve_table_name(
/// 从 plugins 表解析 manifest metadata.id如 "erp-crm"
pub async fn resolve_manifest_id(
plugin_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AppResult<String> {
let model = plugin::Entity::find()
.filter(plugin::Column::Id.eq(plugin_id))
.filter(plugin::Column::TenantId.eq(tenant_id))
.filter(plugin::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| AppError::NotFound(format!("插件 {} 不存在", plugin_id)))?;
let manifest: crate::manifest::PluginManifest =
serde_json::from_value(model.manifest_json)
.map_err(|e| AppError::Internal(format!("解析插件 manifest 失败: {}", e)))?;
Ok(manifest.metadata.id)
}
/// 从 plugin_entities 表获取实体完整信息(带租户隔离)
async fn resolve_entity_info(
plugin_id: Uuid,
entity_name: &str,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AppResult<String> {
) -> AppResult<EntityInfo> {
let entity = plugin_entity::Entity::find()
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
@@ -240,11 +405,25 @@ async fn resolve_table_name(
.one(db)
.await?
.ok_or_else(|| {
erp_core::error::AppError::NotFound(format!(
"插件实体 {}/{} 不存在",
plugin_id, entity_name
))
AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name))
})?;
Ok(entity.table_name)
Ok(EntityInfo {
table_name: entity.table_name,
schema_json: entity.schema_json,
})
}
/// 校验数据:检查 required 字段
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(())
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,10 +7,23 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::data_dto::{CreatePluginDataReq, PluginDataListParams, PluginDataResp, UpdatePluginDataReq};
use crate::data_service::PluginDataService;
use crate::data_dto::{
AggregateItem, AggregateQueryParams, CountQueryParams, CreatePluginDataReq,
PluginDataListParams, PluginDataResp, UpdatePluginDataReq,
};
use crate::data_service::{PluginDataService, resolve_manifest_id};
use crate::state::PluginState;
/// 计算插件数据操作所需的权限码
/// 格式:{manifest_id}.{entity}.{action},如 erp-crm.customer.list
fn compute_permission_code(manifest_id: &str, entity_name: &str, action: &str) -> String {
let action_suffix = match action {
"list" | "get" => "list",
_ => "manage",
};
format!("{}.{}.{}", manifest_id, entity_name, action_suffix)
}
#[utoipa::path(
get,
path = "/api/v1/plugins/{plugin_id}/{entity}",
@@ -32,11 +45,21 @@ where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.list")?;
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
if require_permission(&ctx, &fine_perm).is_err() {
require_permission(&ctx, "plugin.list")?;
}
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
// 解析 filter JSON
let filter: Option<serde_json::Value> = params
.filter
.as_ref()
.and_then(|f| serde_json::from_str(f).ok());
let (items, total) = PluginDataService::list(
plugin_id,
&entity,
@@ -44,6 +67,10 @@ where
page,
page_size,
&state.db,
filter,
params.search,
params.sort_by,
params.sort_order,
)
.await?;
@@ -77,7 +104,11 @@ where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "create");
if require_permission(&ctx, &fine_perm).is_err() {
require_permission(&ctx, "plugin.admin")?;
}
let result = PluginDataService::create(
plugin_id,
@@ -112,7 +143,11 @@ where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.list")?;
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "get");
if require_permission(&ctx, &fine_perm).is_err() {
require_permission(&ctx, "plugin.list")?;
}
let result =
PluginDataService::get_by_id(plugin_id, &entity, id, ctx.tenant_id, &state.db).await?;
@@ -141,7 +176,11 @@ where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "update");
if require_permission(&ctx, &fine_perm).is_err() {
require_permission(&ctx, "plugin.admin")?;
}
let result = PluginDataService::update(
plugin_id,
@@ -178,7 +217,11 @@ where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "delete");
if require_permission(&ctx, &fine_perm).is_err() {
require_permission(&ctx, "plugin.admin")?;
}
PluginDataService::delete(
plugin_id,
@@ -192,3 +235,100 @@ where
Ok(Json(ApiResponse::ok(())))
}
#[utoipa::path(
get,
path = "/api/v1/plugins/{plugin_id}/{entity}/count",
params(CountQueryParams),
responses(
(status = 200, description = "成功", body = ApiResponse<u64>),
),
security(("bearer_auth" = [])),
tag = "插件数据"
)]
/// GET /api/v1/plugins/{plugin_id}/{entity}/count — 统计计数
pub async fn count_plugin_data<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path((plugin_id, entity)): Path<(Uuid, String)>,
Query(params): Query<CountQueryParams>,
) -> Result<Json<ApiResponse<u64>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
if require_permission(&ctx, &fine_perm).is_err() {
require_permission(&ctx, "plugin.list")?;
}
// 解析 filter JSON
let filter: Option<serde_json::Value> = params
.filter
.as_ref()
.and_then(|f| serde_json::from_str(f).ok());
let total = PluginDataService::count(
plugin_id,
&entity,
ctx.tenant_id,
&state.db,
filter,
params.search,
)
.await?;
Ok(Json(ApiResponse::ok(total)))
}
#[utoipa::path(
get,
path = "/api/v1/plugins/{plugin_id}/{entity}/aggregate",
params(AggregateQueryParams),
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<AggregateItem>>),
),
security(("bearer_auth" = [])),
tag = "插件数据"
)]
/// GET /api/v1/plugins/{plugin_id}/{entity}/aggregate — 聚合查询
pub async fn aggregate_plugin_data<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path((plugin_id, entity)): Path<(Uuid, String)>,
Query(params): Query<AggregateQueryParams>,
) -> Result<Json<ApiResponse<Vec<AggregateItem>>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
if require_permission(&ctx, &fine_perm).is_err() {
require_permission(&ctx, "plugin.list")?;
}
// 解析 filter JSON
let filter: Option<serde_json::Value> = params
.filter
.as_ref()
.and_then(|f| serde_json::from_str(f).ok());
let rows = PluginDataService::aggregate(
plugin_id,
&entity,
ctx.tenant_id,
&state.db,
&params.group_by,
filter,
)
.await?;
let items = rows
.into_iter()
.map(|(key, count)| AggregateItem { key, count })
.collect();
Ok(Json(ApiResponse::ok(items)))
}

View File

@@ -199,7 +199,11 @@ where
&state.db,
&state.engine,
)
.await?;
.await
.map_err(|e| {
tracing::error!(error = %e, "Install failed");
e
})?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -58,6 +58,14 @@ pub struct PluginField {
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>,
}
/// 字段类型
@@ -75,6 +83,56 @@ pub enum PluginFieldType {
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",
Self::DateTime => "TIMESTAMPTZ",
Self::Uuid => "UUID",
}
}
/// Generated Column 的表达式
pub fn generated_expr(&self, field_name: &str) -> String {
match self {
Self::String | Self::Json => 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,
}
}
}
/// 索引定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginIndex {
@@ -93,19 +151,85 @@ pub struct PluginEvents {
/// UI 页面配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginUi {
pub pages: Vec<PluginPage>,
pub pages: Vec<PluginPageType>,
}
/// 插件页面定义
#[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>,
/// 插件页面类型tagged enumTOML 中通过 type 字段区分)
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum PluginPageType {
#[serde(rename = "crud")]
Crud {
entity: String,
label: String,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
enable_search: Option<bool>,
#[serde(default)]
enable_views: Option<Vec<String>>,
},
#[serde(rename = "tree")]
Tree {
entity: String,
label: String,
#[serde(default)]
icon: Option<String>,
id_field: String,
parent_field: String,
label_field: String,
},
#[serde(rename = "detail")]
Detail {
entity: String,
label: String,
sections: Vec<PluginSection>,
},
#[serde(rename = "tabs")]
Tabs {
label: String,
#[serde(default)]
icon: Option<String>,
tabs: Vec<PluginPageType>,
},
#[serde(rename = "graph")]
Graph {
entity: String,
label: String,
#[serde(default)]
icon: Option<String>,
relationship_entity: String,
source_field: String,
target_field: String,
edge_label_field: String,
node_label_field: String,
},
#[serde(rename = "dashboard")]
Dashboard {
label: String,
#[serde(default)]
icon: Option<String>,
},
}
/// 插件页面区段(用于 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>>,
},
}
/// 权限定义
@@ -154,9 +278,85 @@ pub fn parse_manifest(toml_str: &str) -> PluginResult<PluginManifest> {
}
}
// 验证页面类型配置
if let Some(ui) = &manifest.ui {
validate_pages(&ui.pages)?;
}
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 无需额外验证
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -208,11 +408,10 @@ subscribe = ["workflow.task.completed", "order.*"]
[ui]
[[ui.pages]]
route = "/products"
type = "crud"
entity = "product"
display_name = "商品管理"
label = "商品管理"
icon = "ShoppingOutlined"
menu_group = "进销存"
[[permissions]]
code = "product.list"
@@ -229,6 +428,14 @@ description = "查看商品列表"
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]
@@ -259,4 +466,137 @@ 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(), "TIMESTAMPTZ");
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");
}
}

View File

@@ -76,6 +76,15 @@ impl PluginModule {
get(crate::handler::data_handler::get_plugin_data::<S>)
.put(crate::handler::data_handler::update_plugin_data::<S>)
.delete(crate::handler::data_handler::delete_plugin_data::<S>),
)
// 数据统计路由
.route(
"/plugins/{plugin_id}/{entity}/count",
get(crate::handler::data_handler::count_plugin_data::<S>),
)
.route(
"/plugins/{plugin_id}/{entity}/aggregate",
get(crate::handler::data_handler::aggregate_plugin_data::<S>),
);
admin_routes.merge(data_routes)

View File

@@ -1,5 +1,5 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
use uuid::Uuid;
use sha2::{Sha256, Digest};
@@ -76,7 +76,7 @@ impl PluginService {
Ok(plugin_model_to_resp(&model, &manifest, vec![]))
}
/// 安装插件: 创建动态表 + 注册 entity 记录 + 注册事件订阅 + status=installed
/// 安装插件: 创建动态表 + 注册 entity 记录 + 注册事件订阅 + 注册权限 + status=installed
pub async fn install(
plugin_id: Uuid,
tenant_id: Uuid,
@@ -96,11 +96,16 @@ impl PluginService {
// 创建动态表 + 注册 entity 记录
let mut entity_resps = Vec::new();
if let Some(schema) = &manifest.schema {
for entity_def in &schema.entities {
for (i, entity_def) in schema.entities.iter().enumerate() {
let table_name = DynamicTableManager::table_name(&manifest.metadata.id, &entity_def.name);
tracing::info!(step = i, entity = %entity_def.name, table = %table_name, "Creating dynamic table");
// 创建动态表
DynamicTableManager::create_table(db, &manifest.metadata.id, entity_def).await?;
DynamicTableManager::create_table(db, &manifest.metadata.id, entity_def).await
.map_err(|e| {
tracing::error!(entity = %entity_def.name, table = %table_name, error = %e, "Failed to create dynamic table");
e
})?;
// 注册 entity 记录
let entity_id = Uuid::now_v7();
@@ -142,7 +147,26 @@ impl PluginService {
}
}
// 注册插件声明的权限到 permissions 表
tracing::info!("Registering plugin permissions");
if let Some(perms) = &manifest.permissions {
register_plugin_permissions(
db,
tenant_id,
operator_id,
&manifest.metadata.id,
perms,
&now,
)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to register permissions");
e
})?;
}
// 加载到内存
tracing::info!(manifest_id = %manifest.metadata.id, "Loading plugin into engine");
engine
.load(
&manifest.metadata.id,
@@ -236,7 +260,7 @@ impl PluginService {
Ok(plugin_model_to_resp(&model, &manifest, entity_resps))
}
/// 卸载插件: unload + 有条件地 drop 动态表 + status=uninstalled
/// 卸载插件: unload + 有条件地 drop 动态表 + 清理权限 + status=uninstalled
pub async fn uninstall(
plugin_id: Uuid,
tenant_id: Uuid,
@@ -294,6 +318,9 @@ impl PluginService {
}
}
// 清理此插件注册的权限
unregister_plugin_permissions(db, tenant_id, &manifest.metadata.id).await?;
let mut active: plugin::ActiveModel = model.into();
active.status = Set("uninstalled".to_string());
active.updated_at = Set(now);
@@ -438,7 +465,22 @@ impl PluginService {
let manifest: PluginManifest =
serde_json::from_value(model.manifest_json.clone())
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
Ok(serde_json::to_value(&manifest.schema).unwrap_or_default())
// 构建 schema 响应entities + ui 页面配置
let mut result = serde_json::Map::new();
if let Some(schema) = &manifest.schema {
result.insert(
"entities".to_string(),
serde_json::to_value(&schema.entities).unwrap_or_default(),
);
}
if let Some(ui) = &manifest.ui {
result.insert(
"ui".to_string(),
serde_json::to_value(ui).unwrap_or_default(),
);
}
Ok(serde_json::Value::Object(result))
}
/// 清除插件记录(软删除,仅限已卸载状态)
@@ -449,7 +491,7 @@ impl PluginService {
db: &sea_orm::DatabaseConnection,
) -> AppResult<()> {
let model = find_plugin(plugin_id, tenant_id, db).await?;
validate_status(&model.status, "uninstalled")?;
validate_status_any(&model.status, &["uninstalled", "uploaded"])?;
let now = Utc::now();
let mut active: plugin::ActiveModel = model.into();
active.deleted_at = Set(Some(now));
@@ -553,3 +595,145 @@ fn plugin_model_to_resp(
record_version: model.version,
}
}
/// 将插件声明的权限注册到 permissions 表。
///
/// 使用 raw SQL 避免依赖 erp-auth 的 entity 类型。
/// 权限码格式:`{plugin_manifest_id}.{code}`(如 `erp-crm.customer.list`)。
/// 使用 `ON CONFLICT DO NOTHING` 保证幂等。
async fn register_plugin_permissions(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
operator_id: Uuid,
plugin_manifest_id: &str,
perms: &[crate::manifest::PluginPermission],
now: &chrono::DateTime<chrono::Utc>,
) -> AppResult<()> {
for perm in perms {
let full_code = format!("{}.{}", plugin_manifest_id, perm.code);
let resource = plugin_manifest_id.to_string();
let action = perm.code.clone();
let description: Option<String> = if perm.description.is_empty() {
None
} else {
Some(perm.description.clone())
};
let sql = r#"
INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $7, $8, $8, NULL, 1)
ON CONFLICT (tenant_id, code) WHERE deleted_at IS NULL DO NOTHING
"#;
db.execute(sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
vec![
sea_orm::Value::from(tenant_id),
sea_orm::Value::from(full_code.clone()),
sea_orm::Value::from(perm.name.clone()),
sea_orm::Value::from(resource),
sea_orm::Value::from(action),
sea_orm::Value::from(description),
sea_orm::Value::from(now.clone()),
sea_orm::Value::from(operator_id),
],
))
.await
.map_err(|e| {
tracing::error!(
plugin = plugin_manifest_id,
permission = %full_code,
error = %e,
"注册插件权限失败"
);
PluginError::DatabaseError(format!("注册插件权限 {} 失败: {}", full_code, e))
})?;
}
tracing::info!(
plugin = plugin_manifest_id,
count = perms.len(),
tenant_id = %tenant_id,
"插件权限注册完成"
);
Ok(())
}
/// 清理插件注册的权限(软删除)。
///
/// 使用 raw SQL 按前缀匹配清理:`{plugin_manifest_id}.%`。
/// 同时清理 role_permissions 中对这些权限的关联。
async fn unregister_plugin_permissions(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
plugin_manifest_id: &str,
) -> AppResult<()> {
let prefix = format!("{}.%", plugin_manifest_id);
let now = chrono::Utc::now();
// 先软删除 role_permissions 中的关联
let rp_sql = r#"
UPDATE role_permissions
SET deleted_at = $1, updated_at = $1
WHERE permission_id IN (
SELECT id FROM permissions
WHERE tenant_id = $2
AND code LIKE $3
AND deleted_at IS NULL
)
AND deleted_at IS NULL
"#;
db.execute(sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
rp_sql,
vec![
sea_orm::Value::from(now.clone()),
sea_orm::Value::from(tenant_id),
sea_orm::Value::from(prefix.clone()),
],
))
.await
.map_err(|e| {
tracing::error!(
plugin = plugin_manifest_id,
error = %e,
"清理插件权限角色关联失败"
);
PluginError::DatabaseError(format!("清理插件权限角色关联失败: {}", e))
})?;
// 再软删除 permissions
let perm_sql = r#"
UPDATE permissions
SET deleted_at = $1, updated_at = $1
WHERE tenant_id = $2
AND code LIKE $3
AND deleted_at IS NULL
"#;
db.execute(sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
perm_sql,
vec![
sea_orm::Value::from(now),
sea_orm::Value::from(tenant_id),
sea_orm::Value::from(prefix),
],
))
.await
.map_err(|e| {
tracing::error!(
plugin = plugin_manifest_id,
error = %e,
"清理插件权限失败"
);
PluginError::DatabaseError(format!("清理插件权限失败: {}", e))
})?;
tracing::info!(
plugin = plugin_manifest_id,
tenant_id = %tenant_id,
"插件权限清理完成"
);
Ok(())
}

View File

@@ -34,6 +34,7 @@ mod m20260415_000030_add_version_to_message_tables;
mod m20260416_000031_create_domain_events;
mod m20260417_000033_create_plugins;
mod m20260417_000034_seed_plugin_permissions;
mod m20260418_000035_pg_trgm_and_entity_columns;
pub struct Migrator;
@@ -75,6 +76,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260414_000032_fix_settings_unique_index_null::Migration),
Box::new(m20260417_000033_create_plugins::Migration),
Box::new(m20260417_000034_seed_plugin_permissions::Migration),
Box::new(m20260418_000035_pg_trgm_and_entity_columns::Migration),
]
}
}

View File

@@ -0,0 +1,77 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 启用 pg_trgm 扩展(加速 ILIKE '%keyword%' 搜索)
manager
.get_connection()
.execute_unprepared("CREATE EXTENSION IF NOT EXISTS pg_trgm")
.await?;
// 插件实体列元数据表 — 记录哪些字段被提取为 Generated Column
manager
.create_table(
Table::create()
.table(Alias::new("plugin_entity_columns"))
.if_not_exists()
.col(
ColumnDef::new(Alias::new("id"))
.uuid()
.not_null()
.default(Expr::cust("gen_random_uuid()"))
.primary_key(),
)
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("plugin_entity_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("field_name")).string().not_null())
.col(ColumnDef::new(Alias::new("column_name")).string().not_null())
.col(ColumnDef::new(Alias::new("sql_type")).string().not_null())
.col(
ColumnDef::new(Alias::new("is_generated"))
.boolean()
.not_null()
.default(true),
)
.col(
ColumnDef::new(Alias::new("created_at"))
.timestamp_with_time_zone()
.not_null()
.default(Expr::cust("NOW()")),
)
.to_owned(),
)
.await?;
// plugin_entity_id 外键
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_plugin_entity_columns_entity")
.from(
Alias::new("plugin_entity_columns"),
Alias::new("plugin_entity_id"),
)
.to(Alias::new("plugin_entities"), Alias::new("id"))
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(
Table::drop()
.table(Alias::new("plugin_entity_columns"))
.to_owned(),
)
.await?;
// pg_trgm 不卸载(其他功能可能依赖)
Ok(())
}
}

View File

@@ -1,3 +1,6 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use axum::body::Body;
use axum::extract::State;
use axum::http::{Request, StatusCode};
@@ -5,6 +8,7 @@ use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use redis::AsyncCommands;
use serde::Serialize;
use tokio::sync::Mutex;
use crate::state::AppState;
@@ -26,6 +30,53 @@ pub struct RateLimitConfig {
pub key_prefix: String,
}
/// Redis 可用性状态缓存,避免重复连接失败时阻塞。
struct RedisAvailability {
available: AtomicBool,
last_check: Mutex<Instant>,
}
impl RedisAvailability {
fn new() -> Self {
Self {
available: AtomicBool::new(true),
last_check: Mutex::new(Instant::now() - std::time::Duration::from_secs(60)),
}
}
/// 检查是否应该尝试连接 Redis。
/// 如果上次连接失败且冷却期未过,返回 false。
async fn should_try(&self) -> bool {
if self.available.load(Ordering::Relaxed) {
return true;
}
let mut last = self.last_check.lock().await;
// 连接失败后冷却 30 秒再重试
if last.elapsed() > std::time::Duration::from_secs(30) {
*last = Instant::now();
true
} else {
false
}
}
fn mark_ok(&self) {
self.available.store(true, Ordering::Relaxed);
}
async fn mark_failed(&self) {
self.available.store(false, Ordering::Relaxed);
*self.last_check.lock().await = Instant::now();
}
}
/// 全局 Redis 可用性缓存
static REDIS_AVAIL: std::sync::OnceLock<RedisAvailability> = std::sync::OnceLock::new();
fn redis_avail() -> &'static RedisAvailability {
REDIS_AVAIL.get_or_init(RedisAvailability::new)
}
/// 基于 Redis 的 IP 限流中间件。
///
/// 使用 INCR + EXPIRE 实现固定窗口计数器。
@@ -65,12 +116,23 @@ async fn apply_rate_limit(
req: Request<Body>,
next: Next,
) -> Response {
let avail = redis_avail();
// 快速跳过Redis 不可达时直接放行
if !avail.should_try().await {
return next.run(req).await;
}
let key = format!("rate_limit:{}:{}", prefix, identifier);
let mut conn = match redis_client.get_multiplexed_async_connection().await {
Ok(c) => c,
Ok(c) => {
avail.mark_ok();
c
}
Err(e) => {
tracing::warn!(error = %e, "Redis 连接失败,跳过限流");
avail.mark_failed().await;
return next.run(req).await;
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,767 @@
# CRM 插件基座升级设计规格 v1.0
> **文档状态:** v1.1 — 已修复评审问题
> **创建日期:** 2026-04-17
> **范围:** JSONB 存储优化 + 数据完整性框架 + 行级数据权限 + 前端页面能力增强
> **评审记录:** code-reviewer 子代理评审通过一轮修复3 Critical + 7 Important
---
## 1. 背景与动机
CRM 插件是 ERP 平台的第一个 WASM 行业插件,已完成 3 阶段 24 任务,包含 5 实体、9 权限、7 页面类型。经 6 个专家组深度评审,发现以下结构性问题需要优先解决:
| 问题 | 严重级别 | 影响 |
|------|---------|------|
| JSONB 动态表类型安全缺失、排序全表扫描 | High | 万级数据以上性能崩溃 |
| JSONB 零外键完整性、零级联策略 | High | 数据"脏"掉,引用断裂 |
| 行级数据权限缺失 | Critical | 销售A能看到销售B的所有客户 |
| plugin.admin 权限 fallback 过宽 | Critical | 超级用户权限泄露 |
| 无关联选择器 (entity_select) | High | UX 极差客户ID手动输入 |
| 无看板/批量操作/图表等页面能力 | Medium | CRM 功能不完整 |
**核心原则:** 基座优先。所有改进沉淀为插件平台通用能力CRM 作为第一受益者而非唯一受益者。
---
## 2. 设计目标
1. **JSONB 存储优化** — 百万级数据下列表查询 p95 < 200ms搜索 p95 < 300ms
2. **数据完整性框架** — 应用层外键校验、级联策略、字段校验、循环引用检测
3. **行级数据权限** — 支持 self/department/department_tree/all 四级数据范围
4. **前端页面能力增强** — 关联选择器、看板页面、批量操作、Dashboard 图表、visible_when 增强
---
## 3. JSONB 存储优化
### 3.1 Generated Column 混合存储
利用 PostgreSQL 12+ 的 `GENERATED ALWAYS AS ... STORED` 列,自动从 JSONB `data` 列提取高频字段到独立列。数据只存一份(在 JSONB 中Generated Column 是自动派生的,零维护成本。
**提取规则(在 `dynamic_table.rs` 的 `create_table` 中自动判断):**
| 字段特征 | 提取策略 | 原因 |
|----------|---------|------|
| `unique == true` | Generated Column + UNIQUE INDEX | 需要精确唯一性约束 |
| `required == true && (sortable \|\| filterable)` | Generated Column + INDEX | 需要类型化排序/筛选 |
| `sortable == true` | Generated Column + INDEX | ORDER BY 走 B-tree |
| `filterable == true` | Generated Column + INDEX | WHERE 走索引扫描 |
| `searchable == true` | 保留 JSONB + pg_trgm GIN 索引 | 模糊搜索用三元组索引 |
| 其他字段 | 保留 JSONB | 无需索引 |
**生成的 DDL 示例:**
```sql
CREATE TABLE plugin_erp_crm_customer (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
data JSONB NOT NULL DEFAULT '{}',
-- Generated Columns
_f_code TEXT GENERATED ALWAYS AS (data->>'code') STORED,
_f_name TEXT GENERATED ALWAYS AS (data->>'name') STORED,
_f_customer_type TEXT GENERATED ALWAYS AS (data->>'customer_type') STORED,
_f_status TEXT GENERATED ALWAYS AS (data->>'status') STORED,
_f_level TEXT GENERATED ALWAYS AS (data->>'level') STORED,
-- 标准字段
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID,
updated_by UUID,
deleted_at TIMESTAMPTZ,
version INT NOT NULL DEFAULT 1
);
-- 复合索引tenant_id 在前,支持多租户过滤)
CREATE INDEX IF NOT EXISTS idx_{t}_tenant_cover
ON "{t}" (tenant_id, created_at DESC)
INCLUDE (id, data, updated_at, version)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_{t}_f_name_sort
ON "{t}" (tenant_id, _f_name)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_{t}_f_code_uniq
ON "{t}" (tenant_id, _f_code)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_{t}_f_type_filter
ON "{t}" (tenant_id, _f_customer_type)
WHERE deleted_at IS NULL;
```
**SQL 查询路由:**`dynamic_table.rs` 中新增 `GeneratedColumnInfo` 结构,记录哪些字段被提取为 Generated Column。`build_filtered_query_sql``build_aggregate_sql` 检测到对应 Generated Column 存在时,自动将 `data->>'field'` 替换为 `_f_{field}`
**类型映射:** `data->>'field'` 始终返回 TEXT。对于非字符串类型Generated Column 需要类型转换以支持正确的排序和比较:
| field_type | SQL 类型 | Generated Column 表达式 |
|------------|---------|------------------------|
| String | TEXT | `data->>'field'` |
| Integer | INTEGER | `(data->>'field')::INTEGER` |
| Float | DOUBLE PRECISION | `(data->>'field')::DOUBLE PRECISION` |
| Decimal | NUMERIC(18,4) | `(data->>'field')::NUMERIC` |
| Boolean | BOOLEAN | `(data->>'field')::BOOLEAN` |
| Date | DATE | `(data->>'field')::DATE` |
| DateTime | TIMESTAMPTZ | `(data->>'field')::TIMESTAMPTZ` |
| Uuid | UUID | `(data->>'field')::UUID` |
`dynamic_table.rs``create_table` 根据 `PluginField.field_type` 自动选择正确的 SQL 类型和类型转换表达式。
**元数据表:**
```sql
CREATE TABLE IF NOT EXISTS plugin_entity_columns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL, -- 多租户标准字段
plugin_entity_id UUID NOT NULL REFERENCES plugin_entities(id),
field_name VARCHAR(100) NOT NULL,
column_name VARCHAR(100) NOT NULL, -- 如 _f_name
sql_type VARCHAR(50) NOT NULL, -- 如 TEXT, INTEGER, UUID
is_generated BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
**Schema 演变策略(重新安装/字段变更):**
当前 `service.rs``install` 使用 `CREATE TABLE IF NOT EXISTS`。引入 Generated Column 后,安装流程改为:
1. **首次安装**`CREATE TABLE` 包含所有 Generated Column。
2. **重新安装(同版本)**`IF NOT EXISTS` 跳过表创建。比对 `plugin_entity_columns` 元数据与当前 manifest 的字段列表,执行增量 ALTER
- 新增字段:`ALTER TABLE ADD COLUMN _f_{name} {type} GENERATED ALWAYS AS (...) STORED`
- 删除字段:`ALTER TABLE DROP COLUMN _f_{name}`(仅删除 Generated ColumnJSONB data 中的原始值保留)
- 类型变更PostgreSQL 不支持 ALTER GENERATED COLUMN 的表达式,需 DROP + ADD
3. **插件卸载时**:表被删除,元数据自动清理。
`dynamic_table.rs` 新增 `migrate_table` 方法,接受已有列列表和目标列列表,生成增量 DDL。
### 3.2 pg_trgm 模糊搜索加速
**迁移文件:**`erp-server/migration` 中新增迁移启用 pg_trgm 扩展:
```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
```
**索引创建:** `create_table` 中 searchable 字段的索引从普通 B-tree 改为 GIN 三元组:
```sql
CREATE INDEX IF NOT EXISTS idx_{t}_{f}_trgm
ON "{t}" USING GIN ((data->>'{f}') gin_trgm_ops)
WHERE deleted_at IS NULL;
```
启用后 `ILIKE '%keyword%'` 从全表扫描退化为索引扫描,百万级数据搜索从 2-5s 降至 50-200ms。
### 3.3 Keyset Pagination
**向后兼容设计:** API 同时支持 OFFSET 和 cursor 两种分页模式。
`data_dto.rs``PluginDataListParams` 新增 `cursor` 字段:
```rust
pub struct PluginDataListParams {
pub page: Option<u64>, // 保留,向后兼容
pub page_size: Option<u64>,
pub cursor: Option<String>, // 新增Base64 编码的游标
pub search: Option<String>,
pub filter: Option<String>,
pub sort_by: Option<String>,
pub sort_order: Option<String>,
}
```
`dynamic_table.rs` 中 SQL 构建逻辑:当 `cursor` 存在时使用 keyset 分页:
**游标编码格式:** JSON 结构 `{ "v": [value1, value2, ...], "id": "uuid" }`Base64 编码。`v` 数组存储排序字段的值(与 sort_by 顺序一致),`id` 是记录主键作为最终 tiebreaker。多列排序时 `v` 包含多个值。字段值为 null 时存储 JSON null。
客户端必须在每次请求中同时发送 `cursor``sort_by`/`sort_order`(游标不嵌入排序信息,保持无状态)。
```sql
-- 第一页
SELECT ... ORDER BY _f_name ASC, id ASC LIMIT 20;
-- 后续页cursor 解码后)
SELECT ... WHERE (_f_name, id) > ($cursor_sort_val, $cursor_id)
ORDER BY _f_name ASC, id ASC LIMIT 20;
```
### 3.4 Schema 缓存
`PluginState` 中添加 `moka` LRU 缓存,消除每次数据请求的 `resolve_entity_info` 查库:
```rust
pub entity_cache: Cache<String, EntityInfo>, // key: "{plugin_id}:{entity_name}:{tenant_id}"
```
TTL 5 分钟,容量 1000 条。
### 3.5 聚合 Redis 缓存
`data_service.rs` 的 create/update/delete 成功后增量更新 Redis 统计:
```
plugin:{plugin_id}:{entity}:count:{tenant_id} → 计数值
plugin:{plugin_id}:{entity}:agg:{field}:{tenant_id} → JSON {key: count}
```
Dashboard 查询直接从 Redis 读取TTL 5 分钟兜底。
### 3.6 性能 SLA 目标
**测试条件:** PostgreSQL 与应用同机部署Redis localhost 延迟 < 1ms。SLA 包含 Redis 往返schema 缓存 + 部门缓存。冷启动Redis 缓存未命中)首次查询允许 3x SLA 宽限。
| 查询场景 | 数据量 | p50 | p95 | p99 |
|----------|--------|-----|-----|-----|
| 按 ID 获取单条 | 100万 | < 5ms | < 10ms | < 20ms |
| 列表查询(默认排序) | 100万 | < 20ms | < 50ms | < 100ms |
| 列表查询(字段排序) | 100万 | < 30ms | < 100ms | < 200ms |
| 搜索ILIKE | 100万 | < 50ms | < 100ms | < 300ms |
| 聚合查询 | 100万 | < 50ms (缓存) | < 500ms (实时) | - |
| Dashboard 全量加载 | 100万 | < 200ms | < 500ms | - |
### 3.7 涉及文件
| 文件 | 改动类型 |
|------|---------|
| `crates/erp-plugin/src/dynamic_table.rs` | 主要改动 — Generated Column DDL、索引策略、SQL 路由、keyset 分页 |
| `crates/erp-plugin/src/data_service.rs` | 缓存逻辑、聚合 Redis 缓存 |
| `crates/erp-plugin/src/data_dto.rs` | 新增 cursor 参数 |
| `crates/erp-plugin/src/state.rs` | 新增 entity_cache |
| `crates/erp-plugin/src/manifest.rs` | PluginEntityColumns 元数据 |
| `crates/erp-server/migration/src/` | pg_trgm 扩展 + plugin_entity_columns 表 |
---
## 4. 数据完整性框架
### 4.1 外键引用声明
`manifest.rs``PluginField` 新增 `ref_entity` 字段:
```rust
pub struct PluginField {
pub name: String,
pub field_type: PluginFieldType,
// ...已有字段...
pub ref_entity: Option<String>, // 新增:引用的实体名
}
```
manifest TOML 中的使用方式:
```toml
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
required = true
display_name = "所属客户"
ref_entity = "customer" # 声明外键引用
```
### 4.2 应用层外键校验
`data_service.rs``validate_data` 函数中扩展:
```
create/update 时:
遍历 fields如果 field.ref_entity 存在:
1. 从 data 中取出该字段的 UUID 值
2. 如果值为 null 或空字符串且 required == false → 跳过校验
3. 如果是自引用ref_entity == 当前实体名)且为 create 操作:
a. 如果引用的是自身 ID → 跳过(记录尚不存在,无法校验)
b. 如果引用的是其他记录 → 正常校验
4. 查询 ref_entity 对应的动态表,验证该记录存在且未删除
5. 不存在则返回 ValidationError
TOCTOU 竞态说明:
外键校验与引用记录删除之间存在理论上的竞态窗口。
对于 JSONB 动态表,这是可接受的风险——应用层校验已大幅降低孤立引用概率。
如果未来需要严格保证,可在 flush_ops 中增加二次校验(事务内 SELECT FOR UPDATE
```
### 4.3 级联删除策略
`manifest.rs` 新增 `PluginRelation` 结构:
```rust
pub struct PluginRelation {
pub entity: String, // 关联实体名
pub foreign_key: String, // 关联实体中的外键字段名
pub on_delete: OnDeleteStrategy, // 级联策略
}
pub enum OnDeleteStrategy {
Nullify, // 置空外键字段
Cascade, // 级联软删除
Restrict, // 存在关联时拒绝删除
}
```
manifest TOML 中的使用方式:
```toml
[[schema.entities.relations]]
entity = "contact"
foreign_key = "customer_id"
on_delete = "nullify"
[[schema.entities.relations]]
entity = "communication"
foreign_key = "customer_id"
on_delete = "cascade"
[[schema.entities.relations]]
entity = "customer_tag"
foreign_key = "customer_id"
on_delete = "cascade"
```
`data_service.rs``delete` 方法中,在软删除记录之前:
```
1. 从 manifest 中查找该实体声明的所有 relations
2. 对每个 relation
- Restrict: 查询关联实体是否有引用 → 有则拒绝删除
- Nullify: 批量 UPDATE 关联记录,将 foreign_key 设为 null
- Cascade: 批量软删除关联记录(级联深度上限 3 层,防止 A→B→C→D 无限递归)
```
### 4.4 字段校验规则
`manifest.rs``PluginField` 新增 `validation` 子结构:
```rust
pub struct FieldValidation {
pub pattern: Option<String>, // 正则表达式
pub message: Option<String>, // 校验失败提示
}
```
manifest TOML 中的使用方式:
```toml
[[schema.entities.fields]]
name = "phone"
field_type = "string"
display_name = "手机号"
validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" }
[[schema.entities.fields]]
name = "email"
field_type = "string"
display_name = "邮箱"
validation = { pattern = "^[\\w.-]+@[\\w.-]+\\.\\w+$", message = "邮箱格式不正确" }
```
`validate_data` 扩展:对有 `validation.pattern` 的字段,使用 `regex` crate 做正则匹配。
### 4.5 循环引用检测
`manifest.rs``PluginField` 新增 `no_cycle` 字段:
```toml
[[schema.entities.fields]]
name = "parent_id"
field_type = "uuid"
ref_entity = "customer"
no_cycle = true # 声明不允许循环引用
```
`data_service.rs``update` 方法中,当 `no_cycle == true` 的字段被修改时:
```
1. 从 data 中取出新值 (new_parent_id)
2. 初始化 visited = {record_id}
3. 循环:查询 current 的 parent_id → 如果在 visited 中则报错 → 加入 visited
4. 直到 parent_id 为 null 或到达根节点
```
### 4.6 涉及文件
| 文件 | 改动类型 |
|------|---------|
| `crates/erp-plugin/src/manifest.rs` | 新增 ref_entity / PluginRelation / FieldValidation / no_cycle |
| `crates/erp-plugin/src/data_service.rs` | 外键校验 / 级联删除 / 字段校验 / 循环检测 |
| `crates/erp-plugin-crm/plugin.toml` | 为现有字段添加 ref_entity / relations / validation / no_cycle 声明 |
---
## 5. 行级数据权限
### 5.1 数据范围模型
在实体级别声明是否启用行级数据权限,在权限级别声明数据范围等级。
**manifest 扩展:**
```toml
[[schema.entities]]
name = "customer"
display_name = "客户"
data_scope = true # 启用行级数据权限
[[schema.entities.fields]]
name = "owner_id"
field_type = "uuid"
display_name = "负责人"
scope_role = "owner" # 标记为数据权限的"所有者"字段
```
**权限声明扩展:**
```toml
[[permissions]]
code = "customer.list"
name = "查看客户"
data_scope_levels = ["self", "department", "department_tree", "all"]
```
### 5.2 数据范围等级定义
| 等级 | 含义 | SQL 条件 |
|------|------|---------|
| `self` | 只看自己负责/创建的 | `data->>'owner_id' = current_user_id OR created_by = current_user_id` |
| `department` | 看本部门所有人的 | `data->>'owner_id' IN (部门用户列表)` |
| `department_tree` | 看本部门及下级部门 | `data->>'owner_id' IN (部门树用户列表)` |
| `all` | 看全部 | 无额外条件 |
### 5.3 实现路径
**TenantContext 扩展:** `erp-core``TenantContext` 结构新增 `department_ids: Vec<Uuid>` 字段注意用户可通过岗位属于多个部门。JWT claims 中新增 `dept_ids` 字段JWT 中间件在构造 TenantContext 时填充。
**多部门用户处理:** 用户通过 Position 关联到多个 Department。`department` 级别取所有所属部门的并集;`department_tree` 取所有所属部门及其下级部门的并集。没有岗位/部门的用户在 `department``department_tree` 级别下只能看到自己创建的数据(降级为 self
**角色权限表扩展:** `role_permissions` 表新增 `data_scope` 字段VARCHAR(32),默认值 `'all'`)。新增迁移文件 `m20260418_*_add_data_scope_to_role_permissions.rs`
```sql
ALTER TABLE role_permissions ADD COLUMN IF NOT EXISTS data_scope VARCHAR(32) NOT NULL DEFAULT 'all';
```
**管理界面适配:** 角色权限分配界面新增"数据范围"下拉选项,管理员为每个权限分配时选择 self/department/department_tree/all。
**查询注入:** `data_service.rs``list` / `count` / `aggregate` 方法中:
```
1. 从权限检查结果中获取该权限对应的 data_scope 等级
2. 如果实体启用了 data_scope
- self: 注入 owner_id / created_by 过滤条件
- department: 查询用户所在部门的所有用户 ID注入 IN 条件
- department_tree: 递归查询部门树,注入 IN 条件
- all: 无额外条件
3. 将条件追加到 dynamic_table 的 SQL 构建中
```
**部门用户缓存:** 使用 Redis 缓存部门-用户映射关系TTL 10 分钟,避免每次查询都递归查部门树。当部门分配变更时通过 EventBus 事件 (`department.member_changed`) 失效缓存。
### 5.4 权限 fallback 收紧
**当前行为(危险):** `data_handler.rs`如果没有实体级权限fallback 到 `plugin.admin`,获得所有数据访问权。
**修改后:** 移除 fallback 逻辑。权限检查链改为:
```
1. 检查实体级权限 ({manifest_id}.{entity}.{action})
2. 存在 → 通过,附带 data_scope
3. 不存在 → 拒绝 (403)
```
`plugin.admin` 只管理插件生命周期(上传/安装/启用/禁用/卸载),不自动获得数据访问权。需要显式分配实体级权限。
**迁移策略(避免现有管理员失去访问):** 在收紧 fallback 的迁移中,同时执行以下补偿:
```sql
-- 为所有拥有 plugin.admin 权限的角色,自动分配所有已安装插件的实体级权限
-- data_scope 默认设为 'all'(管理员级别)
INSERT INTO role_permissions (id, role_id, permission_id, tenant_id, data_scope, ...)
SELECT gen_random_uuid(), rp.role_id, p.id, rp.tenant_id, 'all', ...
FROM role_permissions rp
JOIN permissions p ON p.tenant_id = rp.tenant_id
WHERE rp.permission_id = (SELECT id FROM permissions WHERE code = 'plugin.admin')
AND p.code LIKE 'erp-%' -- 所有插件实体权限
AND NOT EXISTS (
SELECT 1 FROM role_permissions rp2
WHERE rp2.role_id = rp.role_id AND rp2.permission_id = p.id
);
```
这确保现有管理员在 fallback 收紧后仍保持完整的数据访问能力。
### 5.5 涉及文件
| 文件 | 改动类型 |
|------|---------|
| `crates/erp-core/src/types.rs` | TenantContext 新增 department_ids 字段 |
| `crates/erp-auth/src/middleware/jwt_auth.rs` | JWT claims 解析 department_ids |
| `crates/erp-plugin/src/manifest.rs` | data_scope / scope_role / data_scope_levels |
| `crates/erp-plugin/src/data_service.rs` | 查询条件注入 |
| `crates/erp-plugin/src/handler/data_handler.rs` | 移除权限 fallback |
| `crates/erp-plugin/src/dynamic_table.rs` | SQL 构建支持数据范围条件 |
| `crates/erp-plugin-crm/plugin.toml` | customer 实体添加 data_scope / owner_id |
| `crates/erp-server/migration/src/` | 新增 data_scope 列 + 权限补偿迁移 |
---
## 6. 前端页面能力增强
### 6.1 关联选择器 (entity_select)
**Schema 扩展:** `PluginFieldSchema` 新增字段:
```typescript
interface PluginFieldSchema {
// ...已有字段...
ref_entity?: string; // 引用的实体名
ref_label_field?: string; // 显示字段
ref_search_fields?: string[]; // 搜索字段
cascade_from?: string; // 级联过滤来源字段
cascade_filter?: string; // 级联过滤目标字段
}
```
**新增组件:** `EntitySelect.tsx` — 通用远程搜索选择器
```
Props: pluginId, entity, labelField, searchFields, cascadeFrom?, cascadeFilter?, value?, onChange?
内部: listPluginData(pluginId, entity, {search, filter}) → Ant Design Select + showSearch
```
**manifest TOML**
```toml
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
display_name = "所属客户"
ui_widget = "entity_select"
ref_entity = "customer"
ref_label_field = "name"
ref_search_fields = ["name", "code"]
[[schema.entities.fields]]
name = "contact_id"
field_type = "uuid"
display_name = "关联联系人"
ui_widget = "entity_select"
ref_entity = "contact"
ref_label_field = "name"
ref_search_fields = ["name"]
cascade_from = "customer_id" # 选了客户后自动过滤
cascade_filter = "customer_id"
```
### 6.2 Kanban 看板页面
**Schema 扩展:** `PluginPageType` 新增 `Kanban` 变体。
**manifest TOML**
```toml
[[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 = ["name", "code", "region", "status"]
enable_drag = true
```
**新增组件:** `PluginKanbanPage.tsx`
- 使用 `@dnd-kit/core` + `@dnd-kit/sortable` 实现跨列拖拽
- 每列使用 Ant Design Card 渲染卡片
- 每列内支持虚拟滚动(节点数 > 50 时)
- 拖拽结束调用 `PATCH /plugins/{id}/{entity}/{recordId}` 更新 lane_field 值
**后端新增:** `PATCH` 部分更新端点(当前只有 PUT 全量更新):
```
PATCH /api/v1/plugins/{plugin_id}/{entity}/{id}
Body: { "data": { "level": "vip" }, "version": 3 }
```
与 PUT 的区别PATCH 只更新 data 中提供的字段,未提供的字段保持不变。
### 6.3 批量操作
**CRUD 页面增强:** `PluginCRUDPage.tsx` 新增 `rowSelection` 和批量操作栏。
**manifest TOML**
```toml
[[ui.pages]]
type = "crud"
entity = "customer"
enable_batch = true
[[ui.pages.batch_actions]]
label = "批量删除"
action = "batch_delete"
permission = "customer.manage"
confirm = true
[[ui.pages.batch_actions]]
label = "批量修改状态"
action = "batch_update"
update_field = "status"
permission = "customer.manage"
```
**后端新增:** `POST /api/v1/plugins/{id}/{entity}/batch`
```rust
pub enum BatchAction {
BatchDelete { ids: Vec<Uuid> },
BatchUpdate { ids: Vec<Uuid>, data: serde_json::Value },
}
```
批量操作在单个事务中执行,有上限(默认 100 条)。
### 6.4 visible_when 表达式增强
**当前:** 只支持 `field == 'value'` 单一等式。
**增强后支持:**
```toml
visible_when = "customer_type == 'enterprise'"
visible_when = "customer_type == 'enterprise' AND level == 'vip'"
visible_when = "status == 'active' OR status == 'pending'"
visible_when = "NOT status == 'blacklist'"
visible_when = "customer_type == 'enterprise' AND (level == 'vip' OR level == 'svip')"
```
**前端实现:** 新建 `exprEvaluator.ts`,约 100 行递归下降表达式解析器:
```typescript
interface ExprNode {
type: 'eq' | 'and' | 'or' | 'not';
field?: string;
value?: string;
left?: ExprNode;
right?: ExprNode;
operand?: ExprNode;
}
function parseExpr(input: string): ExprNode;
function evaluateExpr(node: ExprNode, values: Record<string, unknown>): boolean;
```
不引入外部依赖,不使用 eval。
### 6.5 Dashboard 图表增强
**Schema 扩展:** Dashboard 页面支持 widgets 声明:
```toml
[[ui.pages]]
type = "dashboard"
label = "统计概览"
[[ui.pages.widgets]]
type = "stat_card"
entity = "customer"
title = "客户总数"
icon = "team"
color = "#4F46E5"
[[ui.pages.widgets]]
type = "bar_chart"
entity = "customer"
title = "客户地区分布"
dimension_field = "region"
metric = "count"
[[ui.pages.widgets]]
type = "pie_chart"
entity = "customer"
title = "客户类型分布"
dimension_field = "customer_type"
metric = "count"
[[ui.pages.widgets]]
type = "funnel_chart"
entity = "customer"
title = "客户等级漏斗"
dimension_field = "level"
dimension_order = ["potential", "normal", "vip", "svip"]
metric = "count"
```
**图表库:** 使用 `@ant-design/charts`Ant Design 生态一致,支持按需引入)。
**后端新增:** timeseries 聚合 API
```
GET /api/v1/plugins/{id}/{entity}/timeseries?time_field=occurred_at&time_grain=week&start=2026-01-01&end=2026-04-17
响应:{ "data": [{ "period": "2026-W01", "count": 12 }, ...] }
```
SQL 实现:`date_trunc('week', (data->>'occurred_at')::timestamp)`
**数据钻取:** 图表点击维度值时跳转到 CRUD 页面并自动带上筛选条件。`PluginCRUDPage` 支持从 URL query 参数初始化筛选。
### 6.6 前端文件拆分
| 当前文件 | 行数 | 拆分方案 |
|---------|------|---------|
| `PluginGraphPage.tsx` | 1081 | → `graphRenderer.ts` + `graphLayout.ts` + `graphInteraction.ts` |
| `PluginCRUDPage.tsx` | 617 | → `CrudTable.tsx` + `CrudForm.tsx` + `CrudDetail.tsx` |
| `PluginDashboardPage.tsx` | 647 | → `DashboardWidgets.tsx` + `dashboardTypes.ts` |
拆分后每个文件控制在 400 行以内。
### 6.7 涉及文件
| 文件 | 改动类型 |
|------|---------|
| `apps/web/src/components/EntitySelect.tsx` | 新增 |
| `apps/web/src/pages/PluginKanbanPage.tsx` | 新增 |
| `apps/web/src/utils/exprEvaluator.ts` | 新增 |
| `apps/web/src/pages/PluginCRUDPage.tsx` | 重构 — 拆分 + 批量操作 + entity_select + visible_when |
| `apps/web/src/pages/PluginGraphPage.tsx` | 重构 — 拆分 |
| `apps/web/src/pages/PluginDashboardPage.tsx` | 重构 — 图表 + 拆分 |
| `apps/web/src/pages/PluginTreePage.tsx` | 优化 — 懒加载 |
| `apps/web/src/api/plugins.ts` | Schema 类型扩展 |
| `apps/web/src/api/pluginData.ts` | 新增 batch / timeseries / cursor API |
| `apps/web/src/App.tsx` | Kanban 路由注册 |
| `crates/erp-plugin/src/handler/data_handler.rs` | 新增 PATCH / batch 端点 |
| `crates/erp-plugin/src/data_service.rs` | batch / timeseries / partial updatePATCH 只合并 data 中的字段) |
| `crates/erp-plugin/src/dynamic_table.rs` | 新增 `build_patch_sql` 部分更新 SQL 构建器 |
---
## 7. 风险与缓解
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|---------|
| Generated Column 的 ALTER TABLE 锁表 | 中 | 中 | 插件安装时在低峰期执行;万级数据以内锁表时间 < 1s |
| pg_trgm 索引空间开销(约 2-3x 原始文本) | 低 | 低 | 只为 searchable 的短文本字段创建 |
| 行级权限的部门查询性能 | 中 | 中 | Redis 缓存部门树TTL 10 分钟 |
| 批量操作事务过大 | 低 | 中 | 上限 100 条;超过则分批执行 |
| 前端重构引入回归 | 中 | 高 | 逐文件拆分,每步验证现有功能不变 |
---
## 8. 不在范围内(后续版本)
以下内容在本次设计中**不涉及**,记录为已知需求:
- WASM Guest 业务逻辑增强 (L2/L3 插件模型)
- 插件版本升级迁移框架
- 跨插件通信 (事件契约 + 只读查询)
- 插件间 RPC / 自定义 API 端点
- 插件市场 / 分发架构
- CRM 新增实体 (lead / opportunity / activity)
- WIT 接口版本化
- 图谱 LOD + WebGL 渲染
- Iframe / Web Component 自定义 UI
这些将在后续的设计规格中详细展开。