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

27 KiB
Raw Blame History

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.rscreate_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 示例:

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_sqlbuild_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.rscreate_table 根据 PluginField.field_type 自动选择正确的 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.rsinstall 使用 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 扩展:

CREATE EXTENSION IF NOT EXISTS pg_trgm;

索引创建: create_table 中 searchable 字段的索引从普通 B-tree 改为 GIN 三元组:

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.rsPluginDataListParams 新增 cursor 字段:

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。

客户端必须在每次请求中同时发送 cursorsort_by/sort_order(游标不嵌入排序信息,保持无状态)。

-- 第一页
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 查库:

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.rsPluginField 新增 ref_entity 字段:

pub struct PluginField {
    pub name: String,
    pub field_type: PluginFieldType,
    // ...已有字段...
    pub ref_entity: Option<String>,  // 新增:引用的实体名
}

manifest TOML 中的使用方式:

[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
required = true
display_name = "所属客户"
ref_entity = "customer"    # 声明外键引用

4.2 应用层外键校验

data_service.rsvalidate_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 结构:

pub struct PluginRelation {
    pub entity: String,          // 关联实体名
    pub foreign_key: String,     // 关联实体中的外键字段名
    pub on_delete: OnDeleteStrategy,  // 级联策略
}

pub enum OnDeleteStrategy {
    Nullify,   // 置空外键字段
    Cascade,   // 级联软删除
    Restrict,  // 存在关联时拒绝删除
}

manifest 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.rsdelete 方法中,在软删除记录之前:

1. 从 manifest 中查找该实体声明的所有 relations
2. 对每个 relation
   - Restrict: 查询关联实体是否有引用 → 有则拒绝删除
   - Nullify: 批量 UPDATE 关联记录,将 foreign_key 设为 null
   - Cascade: 批量软删除关联记录(级联深度上限 3 层,防止 A→B→C→D 无限递归)

4.4 字段校验规则

manifest.rsPluginField 新增 validation 子结构:

pub struct FieldValidation {
    pub pattern: Option<String>,   // 正则表达式
    pub message: Option<String>,   // 校验失败提示
}

manifest 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.rsPluginField 新增 no_cycle 字段:

[[schema.entities.fields]]
name = "parent_id"
field_type = "uuid"
ref_entity = "customer"
no_cycle = true    # 声明不允许循环引用

data_service.rsupdate 方法中,当 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 扩展:

[[schema.entities]]
name = "customer"
display_name = "客户"
data_scope = true   # 启用行级数据权限

[[schema.entities.fields]]
name = "owner_id"
field_type = "uuid"
display_name = "负责人"
scope_role = "owner"  # 标记为数据权限的"所有者"字段

权限声明扩展:

[[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-coreTenantContext 结构新增 department_ids: Vec<Uuid> 字段注意用户可通过岗位属于多个部门。JWT claims 中新增 dept_ids 字段JWT 中间件在构造 TenantContext 时填充。

多部门用户处理: 用户通过 Position 关联到多个 Department。department 级别取所有所属部门的并集;department_tree 取所有所属部门及其下级部门的并集。没有岗位/部门的用户在 departmentdepartment_tree 级别下只能看到自己创建的数据(降级为 self

角色权限表扩展: role_permissions 表新增 data_scope 字段VARCHAR(32),默认值 'all')。新增迁移文件 m20260418_*_add_data_scope_to_role_permissions.rs

ALTER TABLE role_permissions ADD COLUMN IF NOT EXISTS data_scope VARCHAR(32) NOT NULL DEFAULT 'all';

管理界面适配: 角色权限分配界面新增"数据范围"下拉选项,管理员为每个权限分配时选择 self/department/department_tree/all。

查询注入: data_service.rslist / 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 的迁移中,同时执行以下补偿:

-- 为所有拥有 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 新增字段:

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

[[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

[[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

[[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

pub enum BatchAction {
    BatchDelete { ids: Vec<Uuid> },
    BatchUpdate { ids: Vec<Uuid>, data: serde_json::Value },
}

批量操作在单个事务中执行,有上限(默认 100 条)。

6.4 visible_when 表达式增强

当前: 只支持 field == 'value' 单一等式。

增强后支持:

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 行递归下降表达式解析器:

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 声明:

[[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/chartsAnt 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

这些将在后续的设计规格中详细展开。