Files
erp/docs/superpowers/specs/2026-04-17-crm-plugin-base-upgrade-design.md
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

768 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# CRM 插件基座升级设计规格 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
这些将在后续的设计规格中详细展开