513 lines
19 KiB
Markdown
513 lines
19 KiB
Markdown
# 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<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 的查询能力是整个方案的瓶颈
|