19 KiB
CRM 插件 UI 架构审查报告
审查范围:CRM 插件 manifest 配置驱动 UI 方案 审查人:ArchitectUX 日期:2026-04-15
一、现状总结
读完代码后,当前的架构清晰度很高:
- 后端:
PluginManifest(manifest.rs)定义了PluginPage,但只有route/entity/display_name/icon/menu_group五个字段,没有type概念。所有插件页面都走同一条路径渲染。 - 前端路由:
App.tsx只注册了一条通配路由/plugins/:pluginId/:entityName,全部指向PluginCRUDPage。 - CRUD 页面:
PluginCRUDPage有基础的表格+分页+Modal表单+删除,但没有搜索、筛选、排序能力。表格硬编码只展示前 5 个字段。 - 数据层:
DynamicTableManager把所有字段塞进一个 JSONBdata列,查询能力有限(不支持按 JSONB 内字段筛选/排序/搜索)。PluginDataListParams只有page/page_size/search,没有 filter 参数。 - 插件菜单:
PluginStore.refreshMenuItems按 entity 平铺生成菜单项,不支持嵌套/分组/多页面类型。
这意味着 CRM 方案中设想的 type="tabs"/tree/graph/timeline 在 manifest 结构、后端 API、前端路由、前端渲染器四个层面都没有基础设施。下面逐项给出具体建议。
二、逐项审查与建议
2.1 CRUD 页面增强:筛选、搜索、标签过滤
问题:CRM 客户列表需要按类型/等级/地区/行业/状态筛选,按名称/编码模糊搜索,按标签过滤。现有 PluginField 没有"是否可搜索/可筛选"的概念,后端也没有 filter 参数。
建议:分两层扩展。
Manifest 层 -- 在 PluginField 上新增配置:
[[schema.entities.fields]]
name = "customer_type"
field_type = "string"
display_name = "客户类型"
ui_widget = "select"
filterable = true # 新增:出现在筛选栏
options = [
{ label = "企业", value = "enterprise" },
{ label = "个人", value = "individual" },
]
[[schema.entities.fields]]
name = "name"
field_type = "string"
display_name = "客户名称"
searchable = true # 新增:出现在搜索框
新增字段语义:
searchable: bool-- 该字段参与模糊搜索(后端用data->>'name' ILIKE '%keyword%')filterable: bool-- 该字段出现在筛选栏,根据ui_widget自动选择筛选控件(select 用 Select,date 用 DatePicker 等)sortable: bool-- 该字段可排序(后端用ORDER BY data->>'field')
后端层 -- 扩展 PluginDataListParams:
pub struct PluginDataListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub search: Option<String>, // 已有,保持
pub filters: Option<String>, // 新增:JSON 格式 {"customer_type":"enterprise","level":"A"}
pub sort_by: Option<String>, // 新增:字段名
pub sort_order: Option<String>, // 新增:asc/desc
}
在 DynamicTableManager 中根据 searchable/filterable 字段动态拼接 WHERE 子句和 ORDER BY。JSONB 字段的索引需要用 GIN 索引或 expression index 支持。
前端层 -- PluginCRUDPage 增加筛选栏区域:
从 schema 中提取 filterable=true 的字段,在表格上方渲染筛选控件。从 searchable=true 的字段提取字段名,传给搜索框的 placeholder 提示(如"搜索客户名称/编码")。
评估:这个方案是渐进式的,不需要引入新的页面类型,只需扩展现有 crud 的能力。实现优先级最高。
2.2 客户详情页
问题:CRM 的客户详情需要展示基本信息+关联联系人+沟通记录时间线+关系图谱。当前没有"详情页"概念。
建议:新增 detail 页面类型,作为 CRUD 的行级扩展,不需要独立路由。
Manifest 设计:
[[ui.pages]]
type = "crud"
label = "客户列表"
entity = "customer"
detail = { # 新增:点击行进入详情
layout = "tabs", # 详情页内部布局用 tabs 组织
sections = [
{ type = "fields", label = "基本信息", fields = ["name","code","customer_type","level","industry","region","status"] },
{ type = "crud", label = "联系人", entity = "contact", parent_field = "customer_id" },
{ type = "timeline", label = "沟通记录", entity = "communication", date_field = "created_at", content_field = "content" },
{ type = "graph", label = "关系图谱", entity = "customer_relationship", source_field = "from_customer_id", target_field = "to_customer_id" },
]
}
实现方式:
- 表格行增加"查看"按钮(或行点击事件),打开一个 Drawer/新页面
- 路由:
/plugins/:pluginId/:entityName/:id-- 复用现有路由增加可选:id段 - 详情页是一个容器组件,根据
detail.sections配置渲染 tabs,每个 tab 内部递归使用已有的渲染器(crud/tree/timeline/graph) - "基本信息" tab 用 Ant Design
Descriptions组件渲染,fields指定显示哪些字段及顺序
为什么不用单独的 tabs 页面类型包裹:tabs 作为顶级页面类型适合管理视角(如"客户管理"入口),但详情页是某个具体记录的上下文视图,两者是不同层级。顶级 tabs 是菜单入口,详情页 tabs 是数据上下文。
优先级:高。CRM 的核心体验就是从列表进入详情。建议先实现 fields + crud 两种 section 类型,timeline 和 graph 作为后续迭代。
2.3 关系图谱的交互设计
问题:graph 类型需要节点点击、拖拽、缩放、关系筛选等交互。如何配置控制?
建议:graph 页面是所有新类型中复杂度最高的,建议分两个阶段。
第一阶段 -- 基础配置:
type = "graph"
entity = "customer_relationship"
source_entity = "customer" # 节点数据来源
source_field = "from_customer_id" # 关系起点
target_field = "to_customer_id" # 关系终点
edge_label_field = "relationship_type" # 边上的标签
node_label_field = "name" # 节点上的标签
node_color_field = "customer_type" # 按字段值区分节点颜色
interactions = ["click", "zoom", "drag"] # 启用的交互,默认全部
on_click = "drawer" # 点击节点打开 drawer 查看详情
第二阶段 -- 高级配置(CRM 专属,不急于实现):
[[ui.pages.graph.filters]]
field = "relationship_type"
label = "关系类型"
multiple = true
[[ui.pages.graph.node_styles]]
value = "enterprise"
color = "#2563EB"
size = 40
[[ui.pages.graph.node_styles]]
value = "individual"
color = "#059669"
size = 30
技术选型:建议使用 Ant Design 内置能力 + 一个轻量图谱库(如 antv/G6 或 react-force-graph)。G6 是 AntV 生态的,和 Ant Design 风格一致,社区活跃,配置驱动友好。
优先级:中。图谱是 CRM 的差异化功能,但实现成本高。第一阶段可以先做一个静态展示(节点+边+基础交互),第二阶段再做筛选和样式。
2.4 个人/企业客户的差异化表单
问题:同一个 customer 实体,企业客户显示 credit_code,个人客户显示 id_number。
建议:在 PluginField 上新增 visible_when 条件表达式。
[[schema.entities.fields]]
name = "customer_type"
field_type = "string"
display_name = "客户类型"
ui_widget = "select"
required = true
options = [
{ label = "企业", value = "enterprise" },
{ label = "个人", value = "individual" },
]
[[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 == 'individual'" # 条件显示
visible_when 语法:采用简单表达式,前端解析执行。
支持的表达式类型:
field_name == 'value'-- 字段等于某值field_name != 'value'-- 字段不等于某值field_name in ['a','b']-- 字段在列表中
前端实现:Form.useWatch 监听 customer_type 值变化,动态显示/隐藏字段。Ant Design Form 的 shouldUpdate 或 noStyle + 条件渲染即可实现。
后端无需改动 -- visible_when 是纯前端概念,后端只管存 data JSONB,不做校验。
评估:实现成本低,收益高。visible_when 是通用能力,不只服务于 CRM,任何需要条件表单的插件都能用到。优先级高。
2.5 树形页面
问题:客户层级树需要展开/折叠、拖拽排序、点击节点查看详情/子节点。交互够通用吗?
建议:树的交互拆分为"必选"和"可选"两层。
Manifest 配置:
type = "tree"
entity = "customer"
id_field = "id"
parent_field = "parent_id"
label_field = "name"
icon_field = "customer_type" # 可选:根据字段值显示不同图标
default_expand_level = 2 # 默认展开层级
draggable = false # CRM 客户层级通常不允许随意拖拽
on_click = "drawer" # 点击节点:drawer 查看 | crud 子表格 | 路由跳转
actions = ["add_child", "edit", "delete"] # 节点右键/操作按钮
交互拆解:
| 交互 | 必选 | 说明 |
|---|---|---|
| 展开/折叠 | 是 | Ant Design Tree 原生支持 |
| 点击查看详情 | 是 | 打开 Drawer 或跳转 |
| 异步加载子节点 | 是 | 按需加载,避免一次性拉取大树 |
| 搜索节点 | 是 | 过滤树节点,高亮匹配 |
| 拖拽排序 | 否 | draggable = true 时启用,默认关闭 |
| 右键菜单 | 否 | actions 配置后启用 |
后端要求:树形数据需要后端支持两种查询模式:
- 一次性返回所有节点(小数据量)
- 按父节点 ID 分批加载子节点(大数据量)
需要在 PluginDataListParams 增加 parent_id 和 root_only 参数。
通用性评估:足够通用。组织架构、部门、产品分类、地区层级都能用同一套配置。优先级中高。
2.6 时间线视图与 CRUD 列表的切换
问题:时间线和 CRUD 是同一数据的两种视图,用户如何切换?
建议:在 crud 页面类型上增加 views 配置,而不是用 tabs 包裹。
type = "crud"
entity = "communication"
label = "沟通记录"
[crud.views]
default = "table" # 默认视图
alternatives = [
{ type = "timeline", date_field = "created_at", content_field = "content", title_field = "subject" },
]
前端实现:表格右上角增加视图切换按钮组(Ant Design Segmented 或 Radio.Group),类似文件管理器的列表/网格切换。切换后保持筛选条件和分页状态。
为什么不用 tabs:
- tabs 是语义上不同的内容(客户列表 vs 客户层级 vs 关系图谱),各自有独立的数据加载逻辑
- 视图切换是同一数据的不同呈现,共享筛选、分页、搜索状态
- 放在 CRUD 内部可以避免用户在 tab 间切换时丢失筛选上下文
优先级:中。时间线是沟通记录的最佳展示方式,但实现简单(Ant Design Timeline 组件即可),可以在 CRUD 增强后顺手实现。
2.7 配色和视觉一致性
问题:新增页面类型需与现有内置模块风格一致。
现状分析:
- 主题系统已有亮/暗模式(
App.tsx的themeConfig/darkThemeConfig),使用 Ant Design 的ConfigProvider全局注入 - 主色
#4F46E5(Indigo),圆角8px,控件高度36px - 内置模块(如 Users)使用
theme.useToken()获取设计令牌
建议:
- 所有新增页面类型必须使用 Ant Design 组件 -- 树用
Tree/DirectoryTree,时间线用Timeline,标签页用Tabs,详情用Descriptions,抽屉用Drawer。不要引入自定义组件。 - 图谱组件是唯一例外 -- Ant Design 没有图谱组件,需要引入第三方库。选型标准:
- 支持主题定制(节点/边颜色走 Ant Design token)
- 节点样式默认使用
token.colorPrimary/token.colorSuccess等
- 插件页面类型的容器样式统一 -- 所有页面类型共享同一个外层容器组件:
// 插件页面通用容器
function PluginPageContainer({ title, toolbar, children }) {
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0 }}>{title}</h2>
<Space>{toolbar}</Space>
</div>
{children}
</div>
);
}
这与现有 PluginCRUDPage 的布局结构一致(24px padding、flex header、标题+工具栏)。
- 不要在插件 manifest 中暴露颜色配置 -- 颜色由基座主题系统统一管控。插件如果需要区分视觉(如节点类型着色),使用语义化的 token 名称(
success/error/warning)而非具体色值。
三、Manifest 结构重构建议
当前 PluginPage 结构过于简单,无法支撑多种页面类型。建议重构为:
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PluginPage {
/// CRUD 表格页面
Crud(CrudPageConfig),
/// 树形页面
Tree(TreePageConfig),
/// 关系图谱页面
Graph(GraphPageConfig),
/// 标签页容器
Tabs(TabsPageConfig),
/// 仪表盘
Dashboard(DashboardPageConfig),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrudPageConfig {
pub route: String,
pub entity: String,
pub display_name: String,
pub icon: Option<String>,
pub menu_group: Option<String>,
pub detail: Option<DetailConfig>,
pub views: Option<ViewsConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreePageConfig {
pub route: String,
pub entity: String,
pub display_name: String,
pub icon: Option<String>,
pub id_field: String,
pub parent_field: String,
pub label_field: String,
pub draggable: Option<bool>,
pub on_click: Option<String>,
pub default_expand_level: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphPageConfig {
pub route: String,
pub entity: String,
pub display_name: String,
pub icon: Option<String>,
pub source_entity: String,
pub source_field: String,
pub target_field: String,
pub edge_label_field: Option<String>,
pub node_label_field: Option<String>,
pub on_click: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TabsPageConfig {
pub route: String,
pub display_name: String,
pub icon: Option<String>,
pub menu_group: Option<String>,
pub tabs: Vec<TabItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TabItem {
pub label: String,
#[serde(flatten)]
pub page: Box<PluginPage>,
}
PluginField 扩展:
pub struct PluginField {
// ... 现有字段 ...
pub searchable: Option<bool>,
pub filterable: Option<bool>,
pub sortable: Option<bool>,
pub visible_when: Option<String>,
pub column_width: Option<u32>,
pub hidden_in_table: Option<bool>,
pub hidden_in_form: Option<bool>,
}
四、前端架构建议
4.1 路由改造
当前路由 /plugins/:pluginId/:entityName 需要改为基于 manifest 的动态路由:
/plugins/:pluginId/*pagePath # pagePath 对应 manifest 中的 route
PluginCRUDPage 重构为 PluginPageRenderer,根据 manifest 的 type 字段分发到不同的渲染器:
PluginPageRenderer
-> PluginCrudPage (type="crud")
-> PluginTreePage (type="tree")
-> PluginGraphPage (type="graph")
-> PluginTabsContainer (type="tabs")
-> PluginDashboardPage (type="dashboard")
4.2 组件文件结构
apps/web/src/
plugins/ # 插件渲染器
PluginPageRenderer.tsx # 入口分发器
PluginPageContainer.tsx # 通用容器(padding、标题、工具栏)
crud/
PluginCrudPage.tsx # CRUD 表格+表单(重构自现有)
CrudFilterBar.tsx # 筛选栏
CrudSearchBar.tsx # 搜索栏
CrudDetailDrawer.tsx # 详情抽屉
CrudFormFields.tsx # 动态表单字段(含 visible_when)
tree/
PluginTreePage.tsx # 树形页面
graph/
PluginGraphPage.tsx # 图谱页面
timeline/
PluginTimelineView.tsx # 时间线视图(作为 CRUD 的备选视图)
tabs/
PluginTabsContainer.tsx # 标签页容器
shared/
manifestParser.ts # manifest 解析工具
filterUtils.ts # 筛选参数构建
4.3 PluginStore 改造
refreshMenuItems 需要从 manifest 的 ui.pages 解析菜单结构,支持嵌套:
interface PluginMenuItem {
key: string; // 路由路径
icon: string;
label: string;
pluginId: string;
pageType: string; // crud/tree/graph/tabs/dashboard
menuGroup?: string;
children?: PluginMenuItem[]; // tabs 类型的子页面
}
五、实施优先级
| 优先级 | 内容 | 理由 |
|---|---|---|
| P0 | Manifest 结构重构(PluginPage enum + PluginField 扩展) | 所有后续工作的基础 |
| P0 | CRUD 增强:searchable/filterable/sortable + 后端 filter 支持 | CRM 最高频操作 |
| P0 | visible_when 条件表单 | 个人/企业客户差异化核心需求 |
| P1 | 详情页(detail 配置 + Drawer 渲染 + fields/crud section) | CRM 核心体验 |
| P1 | 标签页容器(tabs 类型) | CRM 入口页面需要 |
| P1 | 前端路由重构(PluginPageRenderer 分发) | 支持多页面类型 |
| P2 | 树形页面 | 客户层级,通用性高 |
| P2 | 时间线视图(作为 CRUD 备选视图) | 沟通记录展示 |
| P3 | 关系图谱(基础版) | 差异化功能,实现成本高 |
| P4 | 关系图谱(高级版:筛选、样式) | 锦上添花 |
六、风险提示
-
JSONB 查询性能:当数据量大时,
data->>'field' ILIKE '%keyword%'性能堪忧。建议在安装插件时,根据searchable=true的字段自动创建 GIN 索引(gin((data->>'name'))或表达式索引)。 -
图谱库体积:G6 完整包约 1MB+。如果图谱不是核心功能,考虑用 antv/L7 或更轻量的 react-force-graph(~200KB),或者按需加载(dynamic import)。
-
visible_when 表达式安全:前端解析用户配置的表达式需要沙箱化,防止恶意 manifest 注入。用简单的字符串匹配(正则解析
field == 'value'),不要用eval或new Function。 -
向后兼容:现有的
PluginPage(flat struct)需要保持兼容。可以在反序列化时同时支持新旧两种格式,或者在 manifest 版本号上做区分。
七、总结
CRM 插件的"完全配置驱动"方向是正确的。核心设计原则:
- Manifest 扩展而非前端代码 -- 所有 UI 差异通过 manifest 字段声明,前端渲染器统一处理
- 渐进增强 -- 从 CRUD 增强开始,逐步增加新的页面类型,每一步都可交付
- 通用性优先 -- visible_when、filterable、searchable、tree、detail 这些能力不是 CRM 专属的,任何插件都能受益
- 后端能力先行 -- 前端能做多炫取决于后端能查多深。JSONB 的查询能力是整个方案的瓶颈