fix(用户管理): 修复用户列表页面加载失败问题
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

修复用户列表页面加载失败导致测试超时的问题,确保页面元素正确渲染
This commit is contained in:
iven
2026-04-19 08:46:28 +08:00
parent 0ee9d22634
commit 841766b168
174 changed files with 26366 additions and 675 deletions

View File

@@ -0,0 +1,512 @@
# 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 的查询能力是整个方案的瓶颈