# 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` 把所有字段塞进一个 JSONB `data` 列,查询能力有限(不支持按 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` 上新增配置: ```toml [[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`: ```rust pub struct PluginDataListParams { pub page: Option, pub page_size: Option, pub search: Option, // 已有,保持 pub filters: Option, // 新增:JSON 格式 {"customer_type":"enterprise","level":"A"} pub sort_by: Option, // 新增:字段名 pub sort_order: Option, // 新增: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 设计**: ```toml [[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" }, ] } ``` **实现方式**: 1. 表格行增加"查看"按钮(或行点击事件),打开一个 Drawer/新页面 2. 路由:`/plugins/:pluginId/:entityName/:id` -- 复用现有路由增加可选 `:id` 段 3. 详情页是一个容器组件,根据 `detail.sections` 配置渲染 tabs,每个 tab 内部递归使用已有的渲染器(crud/tree/timeline/graph) 4. "基本信息" tab 用 Ant Design `Descriptions` 组件渲染,`fields` 指定显示哪些字段及顺序 **为什么不用单独的 `tabs` 页面类型包裹**:tabs 作为顶级页面类型适合管理视角(如"客户管理"入口),但详情页是某个具体记录的上下文视图,两者是不同层级。顶级 tabs 是菜单入口,详情页 tabs 是数据上下文。 **优先级**:高。CRM 的核心体验就是从列表进入详情。建议先实现 `fields` + `crud` 两种 section 类型,`timeline` 和 `graph` 作为后续迭代。 --- ### 2.3 关系图谱的交互设计 **问题**:graph 类型需要节点点击、拖拽、缩放、关系筛选等交互。如何配置控制? **建议**:graph 页面是所有新类型中复杂度最高的,建议分两个阶段。 **第一阶段 -- 基础配置**: ```toml 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 专属,不急于实现): ```toml [[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` 条件表达式。 ```toml [[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 配置**: ```toml 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` 配置后启用 | **后端要求**:树形数据需要后端支持两种查询模式: 1. 一次性返回所有节点(小数据量) 2. 按父节点 ID 分批加载子节点(大数据量) 需要在 `PluginDataListParams` 增加 `parent_id` 和 `root_only` 参数。 **通用性评估**:足够通用。组织架构、部门、产品分类、地区层级都能用同一套配置。优先级中高。 --- ### 2.6 时间线视图与 CRUD 列表的切换 **问题**:时间线和 CRUD 是同一数据的两种视图,用户如何切换? **建议**:在 `crud` 页面类型上增加 `views` 配置,而不是用 tabs 包裹。 ```toml 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()` 获取设计令牌 **建议**: 1. **所有新增页面类型必须使用 Ant Design 组件** -- 树用 `Tree`/`DirectoryTree`,时间线用 `Timeline`,标签页用 `Tabs`,详情用 `Descriptions`,抽屉用 `Drawer`。不要引入自定义组件。 2. **图谱组件是唯一例外** -- Ant Design 没有图谱组件,需要引入第三方库。选型标准: - 支持主题定制(节点/边颜色走 Ant Design token) - 节点样式默认使用 `token.colorPrimary` / `token.colorSuccess` 等 3. **插件页面类型的容器样式统一** -- 所有页面类型共享同一个外层容器组件: ```tsx // 插件页面通用容器 function PluginPageContainer({ title, toolbar, children }) { return (

{title}

{toolbar}
{children}
); } ``` 这与现有 `PluginCRUDPage` 的布局结构一致(24px padding、flex header、标题+工具栏)。 4. **不要在插件 manifest 中暴露颜色配置** -- 颜色由基座主题系统统一管控。插件如果需要区分视觉(如节点类型着色),使用语义化的 token 名称(`success/error/warning`)而非具体色值。 --- ## 三、Manifest 结构重构建议 当前 `PluginPage` 结构过于简单,无法支撑多种页面类型。建议重构为: ```rust #[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, pub menu_group: Option, pub detail: Option, pub views: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TreePageConfig { pub route: String, pub entity: String, pub display_name: String, pub icon: Option, pub id_field: String, pub parent_field: String, pub label_field: String, pub draggable: Option, pub on_click: Option, pub default_expand_level: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GraphPageConfig { pub route: String, pub entity: String, pub display_name: String, pub icon: Option, pub source_entity: String, pub source_field: String, pub target_field: String, pub edge_label_field: Option, pub node_label_field: Option, pub on_click: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TabsPageConfig { pub route: String, pub display_name: String, pub icon: Option, pub menu_group: Option, pub tabs: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TabItem { pub label: String, #[serde(flatten)] pub page: Box, } ``` `PluginField` 扩展: ```rust pub struct PluginField { // ... 现有字段 ... pub searchable: Option, pub filterable: Option, pub sortable: Option, pub visible_when: Option, pub column_width: Option, pub hidden_in_table: Option, pub hidden_in_form: Option, } ``` --- ## 四、前端架构建议 ### 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` 解析菜单结构,支持嵌套: ```typescript 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 | 关系图谱(高级版:筛选、样式) | 锦上添花 | --- ## 六、风险提示 1. **JSONB 查询性能**:当数据量大时,`data->>'field' ILIKE '%keyword%'` 性能堪忧。建议在安装插件时,根据 `searchable=true` 的字段自动创建 GIN 索引(`gin((data->>'name'))` 或表达式索引)。 2. **图谱库体积**:G6 完整包约 1MB+。如果图谱不是核心功能,考虑用 antv/L7 或更轻量的 react-force-graph(~200KB),或者按需加载(dynamic import)。 3. **visible_when 表达式安全**:前端解析用户配置的表达式需要沙箱化,防止恶意 manifest 注入。用简单的字符串匹配(正则解析 `field == 'value'`),不要用 `eval` 或 `new Function`。 4. **向后兼容**:现有的 `PluginPage`(flat struct)需要保持兼容。可以在反序列化时同时支持新旧两种格式,或者在 manifest 版本号上做区分。 --- ## 七、总结 CRM 插件的"完全配置驱动"方向是正确的。核心设计原则: 1. **Manifest 扩展而非前端代码** -- 所有 UI 差异通过 manifest 字段声明,前端渲染器统一处理 2. **渐进增强** -- 从 CRUD 增强开始,逐步增加新的页面类型,每一步都可交付 3. **通用性优先** -- visible_when、filterable、searchable、tree、detail 这些能力不是 CRM 专属的,任何插件都能受益 4. **后端能力先行** -- 前端能做多炫取决于后端能查多深。JSONB 的查询能力是整个方案的瓶颈