Files
erp/plans/flickering-tinkering-pebble-agent-ae1f1bf7d07977a2d.md
iven 841766b168
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix(用户管理): 修复用户列表页面加载失败问题
修复用户列表页面加载失败导致测试超时的问题,确保页面元素正确渲染
2026-04-19 08:46:28 +08:00

19 KiB
Raw Blame History

CRM 插件 UI 架构审查报告

审查范围CRM 插件 manifest 配置驱动 UI 方案 审查人ArchitectUX 日期2026-04-15


一、现状总结

读完代码后,当前的架构清晰度很高:

  • 后端PluginManifestmanifest.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 上新增配置:

[[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 用 Selectdate 用 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" },
  ]
}

实现方式

  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 类型,timelinegraph 作为后续迭代。


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 的 shouldUpdatenoStyle + 条件渲染即可实现。

后端无需改动 -- 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 配置后启用

后端要求:树形数据需要后端支持两种查询模式:

  1. 一次性返回所有节点(小数据量)
  2. 按父节点 ID 分批加载子节点(大数据量)

需要在 PluginDataListParams 增加 parent_idroot_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 SegmentedRadio.Group),类似文件管理器的列表/网格切换。切换后保持筛选条件和分页状态。

为什么不用 tabs

  • tabs 是语义上不同的内容(客户列表 vs 客户层级 vs 关系图谱),各自有独立的数据加载逻辑
  • 视图切换是同一数据的不同呈现,共享筛选、分页、搜索状态
  • 放在 CRUD 内部可以避免用户在 tab 间切换时丢失筛选上下文

优先级中。时间线是沟通记录的最佳展示方式但实现简单Ant Design Timeline 组件即可),可以在 CRUD 增强后顺手实现。


2.7 配色和视觉一致性

问题:新增页面类型需与现有内置模块风格一致。

现状分析

  • 主题系统已有亮/暗模式(App.tsxthemeConfig / darkThemeConfig),使用 Ant Design 的 ConfigProvider 全局注入
  • 主色 #4F46E5Indigo圆角 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. 插件页面类型的容器样式统一 -- 所有页面类型共享同一个外层容器组件:
// 插件页面通用容器
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、标题+工具栏)。

  1. 不要在插件 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 关系图谱(高级版:筛选、样式) 锦上添花

六、风险提示

  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'),不要用 evalnew Function

  4. 向后兼容:现有的 PluginPageflat struct需要保持兼容。可以在反序列化时同时支持新旧两种格式或者在 manifest 版本号上做区分。


七、总结

CRM 插件的"完全配置驱动"方向是正确的。核心设计原则:

  1. Manifest 扩展而非前端代码 -- 所有 UI 差异通过 manifest 字段声明,前端渲染器统一处理
  2. 渐进增强 -- 从 CRUD 增强开始,逐步增加新的页面类型,每一步都可交付
  3. 通用性优先 -- visible_when、filterable、searchable、tree、detail 这些能力不是 CRM 专属的,任何插件都能受益
  4. 后端能力先行 -- 前端能做多炫取决于后端能查多深。JSONB 的查询能力是整个方案的瓶颈