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

513 lines
19 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 插件 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 用 Selectdate 用 DatePicker 等)
- `sortable: bool` -- 该字段可排序(后端用 `ORDER BY data->>'field'`
**后端层** -- 扩展 `PluginDataListParams`
```rust
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 设计**
```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 (
<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、标题+工具栏)。
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<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` 扩展:
```rust
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` 解析菜单结构,支持嵌套:
```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 的查询能力是整个方案的瓶颈