Compare commits
22 Commits
ff352a4c24
...
3b0b78c4cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b0b78c4cb | ||
|
|
2616e83ec6 | ||
|
|
20734330a6 | ||
|
|
a897cd7a87 | ||
|
|
32dd0f72c1 | ||
|
|
67bdf9e942 | ||
|
|
a7cf44cd46 | ||
|
|
d07e476898 | ||
|
|
2866ffb634 | ||
|
|
b08e8b5ab5 | ||
|
|
f4dd228a67 | ||
|
|
ae62e2ecb2 | ||
|
|
3483395f5e | ||
|
|
b482230a07 | ||
|
|
9effa9f942 | ||
|
|
169e6d1fe5 | ||
|
|
a6d3a0efcc | ||
|
|
92789e6713 | ||
|
|
e68fe8c1b1 | ||
|
|
0ad77693f4 | ||
|
|
472bf244d8 | ||
|
|
52c8821ffa |
185
.claude/skills/plugin-development/SKILL.md
Normal file
185
.claude/skills/plugin-development/SKILL.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# 插件开发 Skill
|
||||
|
||||
基于 CRM 客户管理插件的开发经验,提炼可复用的插件开发流程和模式。
|
||||
|
||||
## 触发场景
|
||||
|
||||
- 用户说"开发一个新插件"、"新建行业模块"、"创建插件"
|
||||
- 用户提到需要在 ERP 平台上扩展新的业务模块
|
||||
|
||||
## 插件开发流程
|
||||
|
||||
### 第一步:需求分析 → 数据模型
|
||||
|
||||
1. 确定插件 ID(如 `erp-crm`、`erp-inventory`)
|
||||
2. 列出实体及其字段,为每个字段标注:
|
||||
- `field_type`: String/Integer/Float/Boolean/Date/DateTime/Uuid/Decimal/Json
|
||||
- `required` / `unique` / `searchable` / `filterable` / `sortable`
|
||||
- `visible_when`: 条件显示表达式(如 `type == 'enterprise'`)
|
||||
- `ui_widget`: 表单控件(input/select/textarea/datepicker)
|
||||
- `options`: select 类型的选项列表
|
||||
|
||||
### 第二步:编写 plugin.toml manifest
|
||||
|
||||
```toml
|
||||
[metadata]
|
||||
id = "erp-xxx"
|
||||
name = "模块名称"
|
||||
version = "0.1.0"
|
||||
description = "描述"
|
||||
author = "ERP Team"
|
||||
min_platform_version = "0.1.0"
|
||||
|
||||
# 权限:{entity}.{list|manage}
|
||||
[[permissions]]
|
||||
code = "entity.list"
|
||||
name = "查看 XX"
|
||||
|
||||
[[permissions]]
|
||||
code = "entity.manage"
|
||||
name = "管理 XX"
|
||||
|
||||
# 实体定义
|
||||
[[schema.entities]]
|
||||
name = "entity"
|
||||
display_name = "实体名"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "field_name"
|
||||
field_type = "String"
|
||||
required = true
|
||||
display_name = "字段名"
|
||||
searchable = true
|
||||
|
||||
# 页面声明
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
entity = "entity"
|
||||
label = "页面标题"
|
||||
icon = "icon-name"
|
||||
enable_search = true
|
||||
```
|
||||
|
||||
### 第三步:创建 Rust crate
|
||||
|
||||
```bash
|
||||
mkdir -p crates/erp-plugin-xxx/src
|
||||
```
|
||||
|
||||
**Cargo.toml**:
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-xxx"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.55"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
```
|
||||
|
||||
**src/lib.rs**:
|
||||
```rust
|
||||
wit_bindgen::generate!({
|
||||
path: "../erp-plugin-prototype/wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
use crate::exports::erp::plugin::plugin_api::Guest;
|
||||
|
||||
struct XxxPlugin;
|
||||
|
||||
impl Guest for XxxPlugin {
|
||||
fn init() -> Result<(), String> { Ok(()) }
|
||||
fn on_tenant_created(_tenant_id: String) -> Result<(), String> { Ok(()) }
|
||||
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> { Ok(()) }
|
||||
}
|
||||
|
||||
export!(XxxPlugin);
|
||||
```
|
||||
|
||||
### 第四步:注册到 workspace
|
||||
|
||||
根 `Cargo.toml` 的 `[workspace] members` 添加 `"crates/erp-plugin-xxx"`。
|
||||
|
||||
### 第五步:编译和转换
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-xxx --target wasm32-unknown-unknown --release
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_xxx.wasm -o target/erp_plugin_xxx.component.wasm
|
||||
```
|
||||
|
||||
### 第六步:上传和测试
|
||||
|
||||
PluginAdmin 页面上传 `.component.wasm` + `plugin.toml`:上传 → 安装 → 启用。
|
||||
|
||||
## 可用页面类型
|
||||
|
||||
| 类型 | 说明 | 必填配置 |
|
||||
|------|------|----------|
|
||||
| `crud` | 增删改查表格 | `entity`, `label` |
|
||||
| `tree` | 树形展示 | `entity`, `label`, `id_field`, `parent_field`, `label_field` |
|
||||
| `detail` | 详情 Drawer | `entity`, `label`, `sections` |
|
||||
| `tabs` | 标签页容器 | `label`, `tabs`(子页面列表) |
|
||||
|
||||
## detail section 类型
|
||||
|
||||
| 类型 | 说明 | 配置 |
|
||||
|------|------|------|
|
||||
| `fields` | 字段描述列表 | `label`, `fields`(字段名数组) |
|
||||
| `crud` | 嵌套 CRUD 表格 | `label`, `entity`, `filter_field` |
|
||||
|
||||
## 字段属性速查
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `searchable` | 可搜索,自动创建 B-tree 索引 |
|
||||
| `filterable` | 可筛选,前端渲染 Select |
|
||||
| `sortable` | 可排序,表格列头排序图标 |
|
||||
| `visible_when` | 条件显示,格式 `field == 'value'` |
|
||||
| `unique` | 唯一约束,CREATE UNIQUE INDEX |
|
||||
| `ui_widget` | 控件:select / textarea |
|
||||
| `options` | select 选项 `[{label, value}]` |
|
||||
|
||||
## 权限规则
|
||||
|
||||
- 格式:`{entity}.{list|manage}`
|
||||
- 安装时自动加 manifest_id 前缀
|
||||
- REST API 动态检查,无精细权限时回退 `plugin.list` / `plugin.admin`
|
||||
|
||||
## REST API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/v1/plugins/{id}/{entity}` | 列表(filter/search/sort) |
|
||||
| POST | `/api/v1/plugins/{id}/{entity}` | 创建(required 校验) |
|
||||
| GET | `/api/v1/plugins/{id}/{entity}/{rid}` | 详情 |
|
||||
| PUT | `/api/v1/plugins/{id}/{entity}/{rid}` | 更新(乐观锁) |
|
||||
| DELETE | `/api/v1/plugins/{id}/{entity}/{rid}` | 软删除 |
|
||||
| GET | `/api/v1/plugins/{id}/{entity}/count` | 统计 |
|
||||
| GET | `/api/v1/plugins/{id}/{entity}/aggregate` | 聚合 |
|
||||
|
||||
## 测试检查清单
|
||||
|
||||
- [ ] `cargo check --workspace` 通过
|
||||
- [ ] `cargo test --workspace` 通过
|
||||
- [ ] WASM 编译 + Component 转换成功
|
||||
- [ ] 上传 → 安装 → 启用流程正常
|
||||
- [ ] CRUD 完整可用
|
||||
- [ ] 唯一字段重复插入返回冲突
|
||||
- [ ] filter/search/sort 查询正常
|
||||
- [ ] visible_when 条件字段动态显示
|
||||
- [ ] 侧边栏菜单正确生成
|
||||
|
||||
## 常见陷阱
|
||||
|
||||
1. 表名格式:`plugin_{sanitized_id}_{sanitized_entity}`,连字符变下划线
|
||||
2. edition 必须是 "2024"
|
||||
3. WIT 路径:`../erp-plugin-prototype/wit/plugin.wit`,不是 `erp-plugin`
|
||||
4. JSONB 无外键约束,Uuid 字段不自动校验引用完整性
|
||||
5. Fuel 限制 1000 万,简单逻辑足够,避免重计算循环
|
||||
6. manifest 中只写 `entity.action`,安装时自动加 manifest_id 前缀
|
||||
27
CLAUDE.md
27
CLAUDE.md
@@ -129,11 +129,18 @@ erp-server (→ 所有 crate,组装入口)
|
||||
|
||||
1. **理解需求** — 确认改动的目标模块和影响范围
|
||||
2. **最小实现** — 只改必要的代码,保持模块边界
|
||||
3. **自动验证** — `cargo check` / `cargo test` / `pnpm dev` 必须通过
|
||||
4. **提交** — 按 §10 规范提交
|
||||
5. **文档同步** — 更新相关文档(如果涉及架构变化)
|
||||
3. **验证通过** — 必须全部通过才可继续:
|
||||
- `cargo check` — 编译无错误
|
||||
- `cargo test --workspace` — 所有测试通过(有相关测试时)
|
||||
- `pnpm dev` — 前端页面可正常渲染(涉及前端时)
|
||||
- 功能验证 — 启动服务实际测试改动是否生效(涉及 API 或 UI 时)
|
||||
4. **提交** — 验证通过后按 §10 规范提交
|
||||
5. **文档同步** — 更新相关文档(如果涉及架构、接口、模块变化)
|
||||
6. **推送到仓库** — 提交后立即 `git push`,确保远程仓库同步
|
||||
|
||||
**铁律:步骤 4 是任务完成的硬性条件。不允许"等一下再提交"。**
|
||||
**铁律:**
|
||||
- **步骤 3 验证不通过 = 任务未完成**,不允许跳过验证直接提交。
|
||||
- **步骤 6 推送是强制环节**,不推送就等于没完成。不允许"等一下再推"。
|
||||
|
||||
---
|
||||
|
||||
@@ -413,7 +420,8 @@ cargo test -p erp-plugin-prototype # 运行插件集成测试
|
||||
| `message` | erp-message |
|
||||
| `config` | erp-config |
|
||||
| `server` | erp-server |
|
||||
| `plugin` | erp-plugin-prototype / erp-plugin-test-sample |
|
||||
| `plugin` | erp-plugin / erp-plugin-prototype / erp-plugin-test-sample |
|
||||
| `crm` | erp-plugin-crm |
|
||||
| `web` | Web 前端 |
|
||||
| `ui` | React 组件 |
|
||||
| `db` | 数据库迁移 |
|
||||
@@ -439,6 +447,8 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
| `docs/superpowers/plans/2026-04-10-erp-platform-base-plan.md` | 平台底座实施计划 |
|
||||
| `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | WASM 插件系统设计规格 |
|
||||
| `docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md` | WASM 插件原型验证计划 |
|
||||
| `docs/superpowers/specs/2026-04-16-crm-plugin-design.md` | CRM 客户管理插件设计规格 |
|
||||
| `docs/superpowers/plans/2026-04-16-crm-plugin-plan.md` | CRM 插件实施计划 |
|
||||
|
||||
所有设计决策以设计规格文档为准。实施计划按阶段拆分,每阶段开始前细化。
|
||||
|
||||
@@ -461,6 +471,7 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
| Phase 6 | 整合与打磨 | ✅ 完成 |
|
||||
| - | WASM 插件原型 (V1-V6) | ✅ 验证通过 |
|
||||
| - | 插件系统集成到主服务 | ✅ 已集成 |
|
||||
| - | CRM 插件 (Phase 1-3) | ✅ 完成 |
|
||||
|
||||
### 已实现模块
|
||||
|
||||
@@ -476,6 +487,7 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
| erp-plugin | 插件管理 (WASM 运行时/生命周期/动态表/数据CRUD) | ✅ 已集成 |
|
||||
| erp-plugin-prototype | WASM 插件 Host 运行时 (Wasmtime + bindgen + Host API) | ✅ 原型验证 |
|
||||
| erp-plugin-test-sample | WASM 测试插件 (Guest trait + Host API 回调) | ✅ 原型验证 |
|
||||
| erp-plugin-crm | CRM 客户管理插件 (5 实体/9 权限/6 页面) | ✅ 完成 |
|
||||
|
||||
<!-- ARCH-SNAPSHOT-END -->
|
||||
|
||||
@@ -494,6 +506,11 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
- ❌ **不要**假设只有单租户 — 从第一天就按多租户设计
|
||||
- ❌ **不要**提前实现远期功能 — 严格按 Phase 计划推进
|
||||
- ❌ **不要**忽略 `version` 字段 — 所有更新操作必须检查乐观锁
|
||||
- ❌ **不要**在动态表 SQL 中拼接用户输入 — 使用 `sanitize_identifier` 防注入
|
||||
- ❌ **不要**在插件 crate 中直接依赖 erp-auth — 权限注册用 raw SQL,保持模块边界
|
||||
- ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过
|
||||
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步
|
||||
- ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档
|
||||
|
||||
### 场景化指令
|
||||
|
||||
|
||||
9
Cargo.lock
generated
9
Cargo.lock
generated
@@ -1251,6 +1251,15 @@ dependencies = [
|
||||
"wasmtime-wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "erp-plugin-crm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "erp-plugin-prototype"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -11,6 +11,7 @@ members = [
|
||||
"crates/erp-plugin-prototype",
|
||||
"crates/erp-plugin-test-sample",
|
||||
"crates/erp-plugin",
|
||||
"crates/erp-plugin-crm",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -16,6 +16,10 @@ const Messages = lazy(() => import('./pages/Messages'));
|
||||
const Settings = lazy(() => import('./pages/Settings'));
|
||||
const PluginAdmin = lazy(() => import('./pages/PluginAdmin'));
|
||||
const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage'));
|
||||
const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => ({ default: m.PluginTabsPage })));
|
||||
const PluginTreePage = lazy(() => import('./pages/PluginTreePage').then((m) => ({ default: m.PluginTreePage })));
|
||||
const PluginGraphPage = lazy(() => import('./pages/PluginGraphPage').then((m) => ({ default: m.PluginGraphPage })));
|
||||
const PluginDashboardPage = lazy(() => import('./pages/PluginDashboardPage').then((m) => ({ default: m.PluginDashboardPage })));
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
@@ -138,6 +142,10 @@ export default function App() {
|
||||
<Route path="/messages" element={<Messages />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/plugins/admin" element={<PluginAdmin />} />
|
||||
<Route path="/plugins/:pluginId/tabs/:pageLabel" element={<PluginTabsPage />} />
|
||||
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
|
||||
<Route path="/plugins/:pluginId/graph/:entityName" element={<PluginGraphPage />} />
|
||||
<Route path="/plugins/:pluginId/dashboard" element={<PluginDashboardPage />} />
|
||||
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
@@ -16,15 +16,32 @@ interface PaginatedDataResponse {
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface PluginDataListOptions {
|
||||
filter?: Record<string, string>;
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export async function listPluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
options?: PluginDataListOptions,
|
||||
) {
|
||||
const params: Record<string, string> = {
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
};
|
||||
if (options?.filter) params.filter = JSON.stringify(options.filter);
|
||||
if (options?.search) params.search = options.search;
|
||||
if (options?.sort_by) params.sort_by = options.sort_by;
|
||||
if (options?.sort_order) params.sort_order = options.sort_order;
|
||||
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedDataResponse }>(
|
||||
`/plugins/${pluginId}/${entity}`,
|
||||
{ params: { page, page_size: pageSize } },
|
||||
{ params },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
@@ -69,3 +86,40 @@ export async function deletePluginData(
|
||||
) {
|
||||
await client.delete(`/plugins/${pluginId}/${entity}/${id}`);
|
||||
}
|
||||
|
||||
export async function countPluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
options?: { filter?: Record<string, string>; search?: string },
|
||||
) {
|
||||
const params: Record<string, string> = {};
|
||||
if (options?.filter) params.filter = JSON.stringify(options.filter);
|
||||
if (options?.search) params.search = options.search;
|
||||
|
||||
const { data } = await client.get<{ success: boolean; data: number }>(
|
||||
`/plugins/${pluginId}/${entity}/count`,
|
||||
{ params },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export interface AggregateItem {
|
||||
key: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export async function aggregatePluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
groupBy: string,
|
||||
filter?: Record<string, string>,
|
||||
) {
|
||||
const params: Record<string, string> = { group_by: groupBy };
|
||||
if (filter) params.filter = JSON.stringify(filter);
|
||||
|
||||
const { data } = await client.get<{ success: boolean; data: AggregateItem[] }>(
|
||||
`/plugins/${pluginId}/${entity}/aggregate`,
|
||||
{ params },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
@@ -113,9 +113,52 @@ export async function updatePluginConfig(id: string, config: Record<string, unkn
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getPluginSchema(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: Record<string, unknown> }>(
|
||||
export async function getPluginSchema(id: string): Promise<PluginSchemaResponse> {
|
||||
const { data } = await client.get<{ success: boolean; data: PluginSchemaResponse }>(
|
||||
`/admin/plugins/${id}/schema`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ── Schema 类型定义 ──
|
||||
|
||||
export interface PluginFieldSchema {
|
||||
name: string;
|
||||
field_type: string;
|
||||
required: boolean;
|
||||
display_name?: string;
|
||||
ui_widget?: string;
|
||||
options?: { label: string; value: string }[];
|
||||
searchable?: boolean;
|
||||
filterable?: boolean;
|
||||
sortable?: boolean;
|
||||
visible_when?: string;
|
||||
unique?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginEntitySchema {
|
||||
name: string;
|
||||
display_name: string;
|
||||
fields: PluginFieldSchema[];
|
||||
}
|
||||
|
||||
export interface PluginSchemaResponse {
|
||||
entities: PluginEntitySchema[];
|
||||
ui?: PluginUiSchema;
|
||||
}
|
||||
|
||||
export interface PluginUiSchema {
|
||||
pages: PluginPageSchema[];
|
||||
}
|
||||
|
||||
export type PluginPageSchema =
|
||||
| { type: 'crud'; entity: string; label: string; icon?: string; enable_search?: boolean; enable_views?: string[] }
|
||||
| { type: 'tree'; entity: string; label: string; icon?: string; id_field: string; parent_field: string; label_field: string }
|
||||
| { type: 'detail'; entity: string; label: string; sections: PluginSectionSchema[] }
|
||||
| { type: 'tabs'; label: string; icon?: string; tabs: PluginPageSchema[] }
|
||||
| { type: 'graph'; entity: string; label: string; relationship_entity: string; source_field: string; target_field: string; edge_label_field: string; node_label_field: string }
|
||||
| { type: 'dashboard'; label: string };
|
||||
|
||||
export type PluginSectionSchema =
|
||||
| { type: 'fields'; label: string; fields: string[] }
|
||||
| { type: 'crud'; label: string; entity: string; filter_field?: string; enable_views?: string[] };
|
||||
|
||||
@@ -658,6 +658,50 @@ body {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
/* Sidebar sub-menu (plugin group) */
|
||||
.erp-sidebar-submenu-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
margin: 6px 8px 2px 8px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #94A3B8;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.erp-sidebar-submenu-title:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #E2E8F0;
|
||||
}
|
||||
|
||||
.erp-sidebar-submenu-title-active {
|
||||
color: #A5B4FC;
|
||||
}
|
||||
|
||||
.erp-sidebar-submenu-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.erp-sidebar-submenu-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.erp-sidebar-item-indented {
|
||||
padding-left: 36px;
|
||||
}
|
||||
|
||||
/* Main layout */
|
||||
.erp-main-layout {
|
||||
transition: margin-left 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, memo, useEffect } from 'react';
|
||||
import { useCallback, useState, memo, useEffect } from 'react';
|
||||
import { Layout, Avatar, Space, Dropdown, Tooltip, theme } from 'antd';
|
||||
import {
|
||||
HomeOutlined,
|
||||
@@ -15,11 +15,16 @@ import {
|
||||
BulbOutlined,
|
||||
BulbFilled,
|
||||
AppstoreOutlined,
|
||||
TeamOutlined,
|
||||
TableOutlined,
|
||||
TagsOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAppStore } from '../stores/app';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { usePluginStore } from '../stores/plugin';
|
||||
import type { PluginMenuGroup } from '../stores/plugin';
|
||||
import NotificationPanel from '../components/NotificationPanel';
|
||||
|
||||
const { Header, Sider, Content, Footer } = Layout;
|
||||
@@ -64,17 +69,19 @@ const SidebarMenuItem = memo(function SidebarMenuItem({
|
||||
isActive,
|
||||
collapsed,
|
||||
onClick,
|
||||
indented,
|
||||
}: {
|
||||
item: MenuItem;
|
||||
isActive: boolean;
|
||||
collapsed: boolean;
|
||||
onClick: () => void;
|
||||
indented?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip title={collapsed ? item.label : ''} placement="right">
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`erp-sidebar-item ${isActive ? 'erp-sidebar-item-active' : ''}`}
|
||||
className={`erp-sidebar-item ${isActive ? 'erp-sidebar-item-active' : ''} ${indented ? 'erp-sidebar-item-indented' : ''}`}
|
||||
>
|
||||
<span className="erp-sidebar-item-icon">{item.icon}</span>
|
||||
{!collapsed && <span className="erp-sidebar-item-label">{item.label}</span>}
|
||||
@@ -83,10 +90,97 @@ const SidebarMenuItem = memo(function SidebarMenuItem({
|
||||
);
|
||||
});
|
||||
|
||||
// 动态图标映射
|
||||
const pluginIconMap: Record<string, React.ReactNode> = {
|
||||
AppstoreOutlined: <AppstoreOutlined />,
|
||||
team: <TeamOutlined />,
|
||||
TeamOutlined: <TeamOutlined />,
|
||||
user: <UserOutlined />,
|
||||
UserOutlined: <UserOutlined />,
|
||||
message: <MessageOutlined />,
|
||||
MessageOutlined: <MessageOutlined />,
|
||||
tags: <TagsOutlined />,
|
||||
TagsOutlined: <TagsOutlined />,
|
||||
apartment: <ApartmentOutlined />,
|
||||
ApartmentOutlined: <ApartmentOutlined />,
|
||||
TableOutlined: <TableOutlined />,
|
||||
DashboardOutlined: <AppstoreOutlined />,
|
||||
};
|
||||
|
||||
function getPluginIcon(iconName: string): React.ReactNode {
|
||||
return pluginIconMap[iconName] || <AppstoreOutlined />;
|
||||
}
|
||||
|
||||
// 插件子菜单组 — 可折叠二级标题 + 三级菜单项
|
||||
const SidebarSubMenu = memo(function SidebarSubMenu({
|
||||
group,
|
||||
collapsed,
|
||||
currentPath,
|
||||
onNavigate,
|
||||
}: {
|
||||
group: PluginMenuGroup;
|
||||
collapsed: boolean;
|
||||
currentPath: string;
|
||||
onNavigate: (key: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const hasActive = group.items.some((item) => currentPath === item.key);
|
||||
|
||||
if (collapsed) {
|
||||
// 折叠模式:显示插件图标,Tooltip 列出所有子项
|
||||
const tooltipContent = group.items.map((item) => item.label).join(' / ');
|
||||
return (
|
||||
<Tooltip title={tooltipContent} placement="right">
|
||||
<div
|
||||
onClick={() => {
|
||||
const first = group.items[0];
|
||||
if (first) onNavigate(first.key);
|
||||
}}
|
||||
className={`erp-sidebar-item ${hasActive ? 'erp-sidebar-item-active' : ''}`}
|
||||
>
|
||||
<span className="erp-sidebar-item-icon"><AppstoreOutlined /></span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`erp-sidebar-submenu-title ${hasActive ? 'erp-sidebar-submenu-title-active' : ''}`}
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
>
|
||||
<span className="erp-sidebar-submenu-arrow">
|
||||
<RightOutlined
|
||||
style={{ fontSize: 10, transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
/>
|
||||
</span>
|
||||
<span className="erp-sidebar-submenu-label">{group.pluginName}</span>
|
||||
</div>
|
||||
{expanded && group.items.map((item) => (
|
||||
<SidebarMenuItem
|
||||
key={item.key}
|
||||
item={{
|
||||
key: item.key,
|
||||
icon: getPluginIcon(item.icon),
|
||||
label: item.label,
|
||||
}}
|
||||
isActive={currentPath === item.key}
|
||||
collapsed={collapsed}
|
||||
onClick={() => onNavigate(item.key)}
|
||||
indented
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
const { sidebarCollapsed, toggleSidebar, theme: themeMode, setTheme } = useAppStore();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { pluginMenuItems, fetchPlugins } = usePluginStore();
|
||||
const pluginMenuItems = usePluginStore((s) => s.pluginMenuItems);
|
||||
const pluginMenuGroups = usePluginStore((s) => s.pluginMenuGroups);
|
||||
const fetchPlugins = usePluginStore((s) => s.fetchPlugins);
|
||||
theme.useToken();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -170,21 +264,17 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
</div>
|
||||
|
||||
{/* 菜单组:插件 */}
|
||||
{pluginMenuItems.length > 0 && (
|
||||
{pluginMenuGroups.length > 0 && (
|
||||
<>
|
||||
{!sidebarCollapsed && <div className="erp-sidebar-group">插件</div>}
|
||||
<div className="erp-sidebar-menu">
|
||||
{pluginMenuItems.map((item) => (
|
||||
<SidebarMenuItem
|
||||
key={item.key}
|
||||
item={{
|
||||
key: item.key,
|
||||
icon: <AppstoreOutlined />,
|
||||
label: item.label,
|
||||
}}
|
||||
isActive={currentPath === item.key}
|
||||
{pluginMenuGroups.map((group) => (
|
||||
<SidebarSubMenu
|
||||
key={group.pluginId}
|
||||
group={group}
|
||||
collapsed={sidebarCollapsed}
|
||||
onClick={() => navigate(item.key)}
|
||||
currentPath={currentPath}
|
||||
onNavigate={(key) => navigate(key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function Home() {
|
||||
loadStats();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [fetchUnreadCount, unreadCount]);
|
||||
}, [fetchUnreadCount]);
|
||||
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
navigate(path);
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
CloudDownloadOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
AppstoreOutlined,
|
||||
HeartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { PluginInfo, PluginStatus } from '../api/plugins';
|
||||
@@ -267,7 +266,7 @@ export default function PluginAdmin() {
|
||||
}}
|
||||
maxCount={1}
|
||||
accept=".wasm"
|
||||
fileList={wasmFile ? [wasmFile as unknown as Parameters<typeof Upload>[0]] : []}
|
||||
fileList={[]}
|
||||
onRemove={() => setWasmFile(null)}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>选择 WASM 文件</Button>
|
||||
|
||||
@@ -14,81 +14,215 @@ import {
|
||||
Tag,
|
||||
message,
|
||||
Popconfirm,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
Segmented,
|
||||
Timeline,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
listPluginData,
|
||||
createPluginData,
|
||||
updatePluginData,
|
||||
deletePluginData,
|
||||
type PluginDataListOptions,
|
||||
} from '../api/pluginData';
|
||||
import { getPluginSchema } from '../api/plugins';
|
||||
import {
|
||||
getPluginSchema,
|
||||
type PluginFieldSchema,
|
||||
type PluginEntitySchema,
|
||||
type PluginPageSchema,
|
||||
type PluginSectionSchema,
|
||||
} from '../api/plugins';
|
||||
|
||||
interface FieldDef {
|
||||
name: string;
|
||||
field_type: string;
|
||||
required: boolean;
|
||||
display_name?: string;
|
||||
ui_widget?: string;
|
||||
options?: { label: string; value: string }[];
|
||||
const { Search } = Input;
|
||||
const { TextArea } = Input;
|
||||
|
||||
/** visible_when 表达式解析 */
|
||||
function parseVisibleWhen(expression: string): { field: string; value: string } | null {
|
||||
const regex = /^(\w+)\s*==\s*'([^']*)'$/;
|
||||
const match = expression.trim().match(regex);
|
||||
if (!match) return null;
|
||||
return { field: match[1], value: match[2] };
|
||||
}
|
||||
|
||||
interface EntitySchema {
|
||||
name: string;
|
||||
display_name: string;
|
||||
fields: FieldDef[];
|
||||
/** 判断字段是否应该显示 */
|
||||
function shouldShowField(
|
||||
allValues: Record<string, unknown>,
|
||||
visibleWhen: string | undefined,
|
||||
): boolean {
|
||||
if (!visibleWhen) return true;
|
||||
const parsed = parseVisibleWhen(visibleWhen);
|
||||
if (!parsed) return true;
|
||||
return String(allValues[parsed.field] ?? '') === parsed.value;
|
||||
}
|
||||
|
||||
export default function PluginCRUDPage() {
|
||||
const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>();
|
||||
interface PluginCRUDPageProps {
|
||||
/** 如果从 tabs/detail 页面内嵌使用,通过 props 传入配置 */
|
||||
pluginIdOverride?: string;
|
||||
entityOverride?: string;
|
||||
filterField?: string;
|
||||
filterValue?: string;
|
||||
enableViews?: string[];
|
||||
/** detail 页面内嵌时使用 compact 模式 */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function PluginCRUDPage({
|
||||
pluginIdOverride,
|
||||
entityOverride,
|
||||
filterField,
|
||||
filterValue,
|
||||
enableViews: enableViewsProp,
|
||||
compact,
|
||||
}: PluginCRUDPageProps = {}) {
|
||||
const routeParams = useParams<{ pluginId: string; entityName: string }>();
|
||||
const pluginId = pluginIdOverride || routeParams.pluginId || '';
|
||||
const entityName = entityOverride || routeParams.entityName || '';
|
||||
|
||||
const [records, setRecords] = useState<Record<string, unknown>[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fields, setFields] = useState<FieldDef[]>([]);
|
||||
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
|
||||
const [displayName, setDisplayName] = useState(entityName || '');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editRecord, setEditRecord] = useState<Record<string, unknown> | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [formValues, setFormValues] = useState<Record<string, unknown>>({});
|
||||
|
||||
// 筛选/搜索/排序 state
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [sortBy, setSortBy] = useState<string | undefined>();
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
// 视图切换
|
||||
const [viewMode, setViewMode] = useState<string>('table');
|
||||
|
||||
// 详情 Drawer
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailRecord, setDetailRecord] = useState<Record<string, unknown> | null>(null);
|
||||
const [detailSections, setDetailSections] = useState<PluginSectionSchema[]>([]);
|
||||
const [allEntities, setAllEntities] = useState<PluginEntitySchema[]>([]);
|
||||
const [allPages, setAllPages] = useState<PluginPageSchema[]>([]);
|
||||
|
||||
// 从 fields 中提取 filterable 字段
|
||||
const filterableFields = fields.filter((f) => f.filterable);
|
||||
|
||||
// 查找是否有 detail 页面
|
||||
const hasDetailPage = allPages.some(
|
||||
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
|
||||
);
|
||||
|
||||
// 可用视图
|
||||
const enableViews = enableViewsProp ||
|
||||
(() => {
|
||||
const page = allPages.find(
|
||||
(p) => p.type === 'crud' && 'entity' in p && p.entity === entityName,
|
||||
);
|
||||
return (page as { enable_views?: string[] })?.enable_views || ['table'];
|
||||
})();
|
||||
|
||||
// 加载 schema
|
||||
useEffect(() => {
|
||||
if (!pluginId) return;
|
||||
getPluginSchema(pluginId)
|
||||
.then((schema) => {
|
||||
const entities = (schema as { entities?: EntitySchema[] }).entities || [];
|
||||
const abortController = new AbortController();
|
||||
|
||||
async function loadSchema() {
|
||||
try {
|
||||
const schema = await getPluginSchema(pluginId!);
|
||||
if (abortController.signal.aborted) return;
|
||||
const entities: PluginEntitySchema[] = (schema as { entities?: PluginEntitySchema[] }).entities || [];
|
||||
setAllEntities(entities);
|
||||
const entity = entities.find((e) => e.name === entityName);
|
||||
if (entity) {
|
||||
setFields(entity.fields);
|
||||
setDisplayName(entity.display_name || entityName || '');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// schema 加载失败时仍可使用
|
||||
});
|
||||
const ui = (schema as { ui?: { pages: PluginPageSchema[] } }).ui;
|
||||
if (ui?.pages) {
|
||||
setAllPages(ui.pages);
|
||||
const detailPage = ui.pages.find(
|
||||
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
|
||||
);
|
||||
if (detailPage && 'sections' in detailPage) {
|
||||
setDetailSections(detailPage.sections);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
message.warning('Schema 加载失败,部分功能不可用');
|
||||
}
|
||||
}
|
||||
|
||||
loadSchema();
|
||||
return () => abortController.abort();
|
||||
}, [pluginId, entityName]);
|
||||
|
||||
const fetchData = useCallback(async (p = page) => {
|
||||
if (!pluginId || !entityName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listPluginData(pluginId, entityName, p);
|
||||
setRecords(result.data.map((r) => ({ ...r.data, _id: r.id, _version: r.version })));
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载数据失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [pluginId, entityName, page]);
|
||||
const fetchData = useCallback(
|
||||
async (p = page, overrides?: { search?: string; sort_by?: string; sort_order?: 'asc' | 'desc' }) => {
|
||||
if (!pluginId || !entityName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const options: PluginDataListOptions = {};
|
||||
const mergedFilters = { ...filters };
|
||||
if (filterField && filterValue) {
|
||||
mergedFilters[filterField] = filterValue;
|
||||
}
|
||||
if (Object.keys(mergedFilters).length > 0) {
|
||||
options.filter = mergedFilters;
|
||||
}
|
||||
const effectiveSearch = overrides?.search ?? searchText;
|
||||
if (effectiveSearch) options.search = effectiveSearch;
|
||||
const effectiveSortBy = overrides?.sort_by ?? sortBy;
|
||||
const effectiveSortOrder = overrides?.sort_order ?? sortOrder;
|
||||
if (effectiveSortBy) {
|
||||
options.sort_by = effectiveSortBy;
|
||||
options.sort_order = effectiveSortOrder;
|
||||
}
|
||||
const result = await listPluginData(pluginId, entityName, p, 20, options);
|
||||
setRecords(
|
||||
result.data.map((r) => ({ ...r.data, _id: r.id, _version: r.version })),
|
||||
);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载数据失败');
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
[pluginId, entityName, page, filters, searchText, sortBy, sortOrder, filterField, filterValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// 筛选变化
|
||||
const handleFilterChange = (fieldName: string, value: string | undefined) => {
|
||||
const newFilters = { ...filters };
|
||||
if (value) {
|
||||
newFilters[fieldName] = value;
|
||||
} else {
|
||||
delete newFilters[fieldName];
|
||||
}
|
||||
setFilters(newFilters);
|
||||
setPage(1);
|
||||
// 直接触发重新查询
|
||||
fetchData(1);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: Record<string, unknown>) => {
|
||||
if (!pluginId || !entityName) return;
|
||||
// 去除内部字段
|
||||
const { _id, _version, ...data } = values as Record<string, unknown> & { _id?: string; _version?: number };
|
||||
const { _id, _version, ...data } = values as Record<string, unknown> & {
|
||||
_id?: string;
|
||||
_version?: number;
|
||||
};
|
||||
|
||||
try {
|
||||
if (editRecord) {
|
||||
@@ -130,6 +264,7 @@ export default function PluginCRUDPage() {
|
||||
dataIndex: f.name,
|
||||
key: f.name,
|
||||
ellipsis: true,
|
||||
sorter: f.sortable ? true : undefined,
|
||||
render: (val: unknown) => {
|
||||
if (typeof val === 'boolean') return val ? <Tag color="green">是</Tag> : <Tag>否</Tag>;
|
||||
return String(val ?? '-');
|
||||
@@ -138,15 +273,28 @@ export default function PluginCRUDPage() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
width: hasDetailPage ? 200 : 150,
|
||||
render: (_: unknown, record: Record<string, unknown>) => (
|
||||
<Space size="small">
|
||||
{hasDetailPage && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => {
|
||||
setDetailRecord(record);
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditRecord(record);
|
||||
form.setFieldsValue(record);
|
||||
setFormValues(record);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -163,7 +311,7 @@ export default function PluginCRUDPage() {
|
||||
];
|
||||
|
||||
// 动态生成表单字段
|
||||
const renderFormField = (field: FieldDef) => {
|
||||
const renderFormField = (field: PluginFieldSchema) => {
|
||||
const widget = field.ui_widget || field.field_type;
|
||||
switch (widget) {
|
||||
case 'number':
|
||||
@@ -186,71 +334,284 @@ export default function PluginCRUDPage() {
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
case 'textarea':
|
||||
return <TextArea rows={3} />;
|
||||
default:
|
||||
return <Input />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 style={{ margin: 0 }}>{displayName}</h2>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setEditRecord(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
// Timeline 视图渲染
|
||||
const renderTimeline = () => {
|
||||
const dateField = fields.find((f) => f.field_type === 'DateTime' || f.field_type === 'date');
|
||||
const titleField = fields.find((f) => f.searchable)?.name || fields[1]?.name;
|
||||
const contentField = fields.find((f) => f.ui_widget === 'textarea')?.name;
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
rowKey="_id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => setPage(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
return (
|
||||
<Timeline
|
||||
items={records.map((record) => ({
|
||||
children: (
|
||||
<div>
|
||||
{titleField && (
|
||||
<p>
|
||||
<strong>{String(record[titleField] ?? '-')}</strong>
|
||||
</p>
|
||||
)}
|
||||
{contentField && <p>{String(record[contentField] ?? '-')}</p>}
|
||||
{dateField && (
|
||||
<p style={{ color: '#999', fontSize: 12 }}>
|
||||
{String(record[dateField.name] ?? '-')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 详情 Drawer 渲染
|
||||
const renderDetailDrawer = () => {
|
||||
if (!detailRecord) return null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={displayName + ' 详情'}
|
||||
open={detailOpen}
|
||||
onClose={() => {
|
||||
setDetailOpen(false);
|
||||
setDetailRecord(null);
|
||||
}}
|
||||
width={640}
|
||||
>
|
||||
{detailSections.length > 0 ? (
|
||||
detailSections.map((section, idx) => {
|
||||
if (section.type === 'fields') {
|
||||
return (
|
||||
<div key={idx} style={{ marginBottom: 24 }}>
|
||||
<h4>{section.label}</h4>
|
||||
<Descriptions column={2} bordered size="small">
|
||||
{section.fields.map((fieldName) => {
|
||||
const fieldDef = fields.find((f) => f.name === fieldName);
|
||||
const val = detailRecord[fieldName];
|
||||
return (
|
||||
<Descriptions.Item
|
||||
key={fieldName}
|
||||
label={fieldDef?.display_name || fieldName}
|
||||
>
|
||||
{typeof val === 'boolean' ? (
|
||||
val ? (
|
||||
<Tag color="green">是</Tag>
|
||||
) : (
|
||||
<Tag>否</Tag>
|
||||
)
|
||||
) : (
|
||||
String(val ?? '-')
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
);
|
||||
})}
|
||||
</Descriptions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (section.type === 'crud') {
|
||||
const secEntity = allEntities.find((e) => e.name === section.entity);
|
||||
return (
|
||||
<div key={idx} style={{ marginBottom: 24 }}>
|
||||
<h4>{section.label}</h4>
|
||||
{secEntity && (
|
||||
<PluginCRUDPage
|
||||
pluginIdOverride={pluginId}
|
||||
entityOverride={section.entity}
|
||||
filterField={section.filter_field}
|
||||
filterValue={String(detailRecord._id ?? '')}
|
||||
enableViews={section.enable_views}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
) : (
|
||||
// 没有 sections 配置时,默认展示所有字段
|
||||
<Descriptions column={2} bordered size="small">
|
||||
{fields.map((field) => {
|
||||
const val = detailRecord[field.name];
|
||||
return (
|
||||
<Descriptions.Item key={field.name} label={field.display_name || field.name}>
|
||||
{typeof val === 'boolean' ? (
|
||||
val ? (
|
||||
<Tag color="green">是</Tag>
|
||||
) : (
|
||||
<Tag>否</Tag>
|
||||
)
|
||||
) : (
|
||||
String(val ?? '-')
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
);
|
||||
})}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={compact ? { padding: 0 } : { padding: 24 }}>
|
||||
{!compact && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0 }}>{displayName}</h2>
|
||||
<Space>
|
||||
{enableViews.length > 1 && (
|
||||
<Segmented
|
||||
options={enableViews.map((v) => ({
|
||||
label: v === 'table' ? '表格' : v === 'timeline' ? '时间线' : v,
|
||||
value: v,
|
||||
}))}
|
||||
value={viewMode}
|
||||
onChange={(val) => setViewMode(val as string)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setEditRecord(null);
|
||||
form.resetFields();
|
||||
setFormValues({});
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索和筛选栏 */}
|
||||
{!compact && (
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
{fields.some((f) => f.searchable) && (
|
||||
<Search
|
||||
placeholder="搜索..."
|
||||
allowClear
|
||||
style={{ width: 240 }}
|
||||
onSearch={(value) => {
|
||||
setSearchText(value);
|
||||
setPage(1);
|
||||
fetchData(1, { search: value });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filterableFields.map((field) => (
|
||||
<Select
|
||||
key={field.name}
|
||||
placeholder={field.display_name || field.name}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
options={field.options || []}
|
||||
onChange={(value) => handleFilterChange(field.name, value)}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{viewMode === 'table' || enableViews.length <= 1 ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
rowKey="_id"
|
||||
loading={loading}
|
||||
size={compact ? 'small' : undefined}
|
||||
onChange={(_pagination, _filters, sorter) => {
|
||||
if (!Array.isArray(sorter) && sorter.field) {
|
||||
const newSortBy = String(sorter.field);
|
||||
const newSortOrder = sorter.order === 'ascend' ? 'asc' as const : 'desc' as const;
|
||||
setSortBy(newSortBy);
|
||||
setSortOrder(newSortOrder);
|
||||
setPage(1);
|
||||
fetchData(1, { sort_by: newSortBy, sort_order: newSortOrder });
|
||||
} else if (!sorter || (Array.isArray(sorter) && sorter.length === 0)) {
|
||||
setSortBy(undefined);
|
||||
setSortOrder('desc');
|
||||
setPage(1);
|
||||
fetchData(1, { sort_by: undefined, sort_order: undefined });
|
||||
}
|
||||
}}
|
||||
pagination={
|
||||
compact
|
||||
? { pageSize: 5, showTotal: (t) => `共 ${t} 条` }
|
||||
: {
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => setPage(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}
|
||||
}
|
||||
/>
|
||||
) : viewMode === 'timeline' ? (
|
||||
renderTimeline()
|
||||
) : null}
|
||||
|
||||
{/* 新增/编辑弹窗 */}
|
||||
<Modal
|
||||
title={editRecord ? '编辑' : '新增'}
|
||||
open={modalOpen}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditRecord(null);
|
||||
setFormValues({});
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
{fields.map((field) => (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
name={field.name}
|
||||
label={field.display_name || field.name}
|
||||
rules={field.required ? [{ required: true, message: `请输入${field.display_name || field.name}` }] : []}
|
||||
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
|
||||
>
|
||||
{renderFormField(field)}
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
onValuesChange={(_, allValues) => setFormValues(allValues)}
|
||||
>
|
||||
{fields.map((field) => {
|
||||
// visible_when 条件显示
|
||||
const visible = shouldShowField(formValues, field.visible_when);
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
name={field.name}
|
||||
label={field.display_name || field.name}
|
||||
rules={
|
||||
field.required
|
||||
? [{ required: true, message: `请输入${field.display_name || field.name}` }]
|
||||
: []
|
||||
}
|
||||
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
|
||||
>
|
||||
{renderFormField(field)}
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 详情 Drawer */}
|
||||
{renderDetailDrawer()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
646
apps/web/src/pages/PluginDashboardPage.tsx
Normal file
646
apps/web/src/pages/PluginDashboardPage.tsx
Normal file
@@ -0,0 +1,646 @@
|
||||
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Row, Col, Spin, Empty, Select, Tag, Progress, Skeleton, theme, Tooltip } from 'antd';
|
||||
import {
|
||||
TeamOutlined,
|
||||
PhoneOutlined,
|
||||
TagsOutlined,
|
||||
RiseOutlined,
|
||||
DashboardOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { countPluginData, aggregatePluginData, type AggregateItem } from '../api/pluginData';
|
||||
import { getPluginSchema, type PluginEntitySchema, type PluginSchemaResponse } from '../api/plugins';
|
||||
|
||||
// ── 类型定义 ──
|
||||
|
||||
interface EntityStat {
|
||||
name: string;
|
||||
displayName: string;
|
||||
count: number;
|
||||
icon: React.ReactNode;
|
||||
gradient: string;
|
||||
iconBg: string;
|
||||
}
|
||||
|
||||
interface FieldBreakdown {
|
||||
fieldName: string;
|
||||
displayName: string;
|
||||
items: AggregateItem[];
|
||||
}
|
||||
|
||||
// ── 色板配置 ──
|
||||
|
||||
const ENTITY_PALETTE: Record<string, { gradient: string; iconBg: string; tagColor: string }> = {
|
||||
customer: {
|
||||
gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)',
|
||||
iconBg: 'rgba(79, 70, 229, 0.12)',
|
||||
tagColor: 'purple',
|
||||
},
|
||||
contact: {
|
||||
gradient: 'linear-gradient(135deg, #059669, #10B981)',
|
||||
iconBg: 'rgba(5, 150, 105, 0.12)',
|
||||
tagColor: 'green',
|
||||
},
|
||||
communication: {
|
||||
gradient: 'linear-gradient(135deg, #D97706, #F59E0B)',
|
||||
iconBg: 'rgba(217, 119, 6, 0.12)',
|
||||
tagColor: 'orange',
|
||||
},
|
||||
customer_tag: {
|
||||
gradient: 'linear-gradient(135deg, #7C3AED, #A78BFA)',
|
||||
iconBg: 'rgba(124, 58, 237, 0.12)',
|
||||
tagColor: 'volcano',
|
||||
},
|
||||
customer_relationship: {
|
||||
gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)',
|
||||
iconBg: 'rgba(225, 29, 72, 0.12)',
|
||||
tagColor: 'red',
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_PALETTE = {
|
||||
gradient: 'linear-gradient(135deg, #2563EB, #3B82F6)',
|
||||
iconBg: 'rgba(37, 99, 235, 0.12)',
|
||||
tagColor: 'blue',
|
||||
};
|
||||
|
||||
const TAG_COLORS = [
|
||||
'blue', 'green', 'orange', 'red', 'purple', 'cyan',
|
||||
'magenta', 'gold', 'lime', 'geekblue', 'volcano',
|
||||
];
|
||||
|
||||
// ── 图标映射 ──
|
||||
|
||||
const ENTITY_ICONS: Record<string, React.ReactNode> = {
|
||||
customer: <TeamOutlined />,
|
||||
contact: <TeamOutlined />,
|
||||
communication: <PhoneOutlined />,
|
||||
customer_tag: <TagsOutlined />,
|
||||
customer_relationship: <RiseOutlined />,
|
||||
};
|
||||
|
||||
// ── 计数动画 Hook ──
|
||||
|
||||
function useCountUp(end: number, duration = 800) {
|
||||
const [count, setCount] = useState(0);
|
||||
const prevEnd = useRef(end);
|
||||
|
||||
useEffect(() => {
|
||||
if (end === prevEnd.current && count > 0) return;
|
||||
prevEnd.current = end;
|
||||
|
||||
if (end === 0) {
|
||||
setCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
function tick(now: number) {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setCount(Math.round(end * eased));
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}, [end, duration]);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
// ── 子组件 ──
|
||||
|
||||
function StatValue({ value, loading }: { value: number; loading: boolean }) {
|
||||
const animatedValue = useCountUp(value);
|
||||
if (loading) return <Spin size="small" />;
|
||||
return <span className="erp-count-up">{animatedValue.toLocaleString()}</span>;
|
||||
}
|
||||
|
||||
/** 顶部统计卡片 */
|
||||
function StatCard({
|
||||
stat,
|
||||
loading,
|
||||
delay,
|
||||
}: {
|
||||
stat: EntityStat;
|
||||
loading: boolean;
|
||||
delay: string;
|
||||
}) {
|
||||
return (
|
||||
<Col xs={12} sm={8} lg={Math.max(4, 24 / 5)} key={stat.name}>
|
||||
<div
|
||||
className={`erp-stat-card ${delay}`}
|
||||
style={
|
||||
{
|
||||
'--card-gradient': stat.gradient,
|
||||
'--card-icon-bg': stat.iconBg,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="erp-stat-card-bar" />
|
||||
<div className="erp-stat-card-body">
|
||||
<div className="erp-stat-card-info">
|
||||
<div className="erp-stat-card-title">{stat.displayName}</div>
|
||||
<div className="erp-stat-card-value">
|
||||
<StatValue value={stat.count} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="erp-stat-card-icon">{stat.icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
/** 骨架屏卡片 */
|
||||
function SkeletonStatCard({ delay }: { delay: string }) {
|
||||
return (
|
||||
<Col xs={12} sm={8} lg={Math.max(4, 24 / 5)}>
|
||||
<div className={`erp-stat-card ${delay}`}>
|
||||
<div className="erp-stat-card-bar" style={{ opacity: 0.3 }} />
|
||||
<div className="erp-stat-card-body">
|
||||
<div className="erp-stat-card-info">
|
||||
<div style={{ width: 80, height: 14, marginBottom: 12 }}>
|
||||
<Skeleton.Input active size="small" style={{ width: 80, height: 14 }} />
|
||||
</div>
|
||||
<div style={{ width: 60, height: 32 }}>
|
||||
<Skeleton.Input active style={{ width: 60, height: 32 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: 48, height: 48 }}>
|
||||
<Skeleton.Avatar active shape="square" size={48} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
/** 字段分布卡片 */
|
||||
function BreakdownCard({
|
||||
breakdown,
|
||||
totalCount,
|
||||
palette,
|
||||
index,
|
||||
}: {
|
||||
breakdown: FieldBreakdown;
|
||||
totalCount: number;
|
||||
palette: { tagColor: string };
|
||||
index: number;
|
||||
}) {
|
||||
const maxCount = Math.max(...breakdown.items.map((i) => i.count), 1);
|
||||
|
||||
return (
|
||||
<Col xs={24} sm={12} lg={8} key={breakdown.fieldName}>
|
||||
<div
|
||||
className={`erp-content-card erp-fade-in`}
|
||||
style={{ animationDelay: `${0.05 * index}s`, opacity: 0 }}
|
||||
>
|
||||
<div className="erp-section-header" style={{ marginBottom: 16 }}>
|
||||
<InfoCircleOutlined className="erp-section-icon" style={{ fontSize: 14 }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--erp-text-primary)',
|
||||
}}
|
||||
>
|
||||
{breakdown.displayName}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 12,
|
||||
color: 'var(--erp-text-tertiary)',
|
||||
}}
|
||||
>
|
||||
{breakdown.items.length} 项
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{breakdown.items.map((item, idx) => {
|
||||
const percent = totalCount > 0 ? Math.round((item.count / totalCount) * 100) : 0;
|
||||
const barPercent = maxCount > 0 ? Math.round((item.count / maxCount) * 100) : 0;
|
||||
const color = TAG_COLORS[idx % TAG_COLORS.length];
|
||||
|
||||
return (
|
||||
<div key={`${breakdown.fieldName}-${item.key}-${idx}`}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<Tooltip title={`${item.key}: ${item.count} (${percent}%)`}>
|
||||
<Tag
|
||||
color={color}
|
||||
style={{
|
||||
margin: 0,
|
||||
maxWidth: '60%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.key || '(空)'}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--erp-text-primary)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{item.count}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 400,
|
||||
color: 'var(--erp-text-tertiary)',
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{percent}%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={barPercent}
|
||||
showInfo={false}
|
||||
strokeColor={color === 'blue' ? '#3B82F6'
|
||||
: color === 'green' ? '#10B981'
|
||||
: color === 'orange' ? '#F59E0B'
|
||||
: color === 'red' ? '#EF4444'
|
||||
: color === 'purple' ? '#8B5CF6'
|
||||
: color === 'cyan' ? '#06B6D4'
|
||||
: color === 'magenta' ? '#EC4899'
|
||||
: color === 'gold' ? '#EAB308'
|
||||
: color === 'lime' ? '#84CC16'
|
||||
: color === 'geekblue' ? '#6366F1'
|
||||
: color === 'volcano' ? '#F97316'
|
||||
: '#3B82F6'}
|
||||
trailColor="var(--erp-border-light)"
|
||||
size="small"
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{breakdown.items.length === 0 && (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无数据"
|
||||
style={{ padding: '12px 0' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
/** 骨架屏分布卡片 */
|
||||
function SkeletonBreakdownCard({ index }: { index: number }) {
|
||||
return (
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<div
|
||||
className="erp-content-card erp-fade-in"
|
||||
style={{ animationDelay: `${0.05 * index}s`, opacity: 0 }}
|
||||
>
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 延迟类名工具 ──
|
||||
|
||||
const DELAY_CLASSES = [
|
||||
'erp-fade-in erp-fade-in-delay-1',
|
||||
'erp-fade-in erp-fade-in-delay-2',
|
||||
'erp-fade-in erp-fade-in-delay-3',
|
||||
'erp-fade-in erp-fade-in-delay-4',
|
||||
'erp-fade-in erp-fade-in-delay-4',
|
||||
];
|
||||
|
||||
function getDelayClass(index: number): string {
|
||||
return DELAY_CLASSES[index % DELAY_CLASSES.length];
|
||||
}
|
||||
|
||||
// ── 主组件 ──
|
||||
|
||||
export function PluginDashboardPage() {
|
||||
const { pluginId } = useParams<{ pluginId: string }>();
|
||||
const { token: themeToken } = theme.useToken();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [schemaLoading, setSchemaLoading] = useState(false);
|
||||
const [entities, setEntities] = useState<PluginEntitySchema[]>([]);
|
||||
const [selectedEntity, setSelectedEntity] = useState<string>('');
|
||||
const [entityStats, setEntityStats] = useState<EntityStat[]>([]);
|
||||
const [breakdowns, setBreakdowns] = useState<FieldBreakdown[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isDark =
|
||||
themeToken.colorBgContainer === '#111827' ||
|
||||
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
|
||||
// 加载 schema
|
||||
useEffect(() => {
|
||||
if (!pluginId) return;
|
||||
const abortController = new AbortController();
|
||||
|
||||
async function loadSchema() {
|
||||
setSchemaLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
|
||||
if (abortController.signal.aborted) return;
|
||||
const entityList = schema.entities || [];
|
||||
setEntities(entityList);
|
||||
if (entityList.length > 0) {
|
||||
setSelectedEntity(entityList[0].name);
|
||||
}
|
||||
} catch {
|
||||
setError('Schema 加载失败,部分功能不可用');
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) setSchemaLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadSchema();
|
||||
return () => abortController.abort();
|
||||
}, [pluginId]);
|
||||
|
||||
const currentEntity = useMemo(
|
||||
() => entities.find((e) => e.name === selectedEntity),
|
||||
[entities, selectedEntity],
|
||||
);
|
||||
|
||||
const filterableFields = useMemo(
|
||||
() => currentEntity?.fields.filter((f) => f.filterable) || [],
|
||||
[currentEntity],
|
||||
);
|
||||
|
||||
// 加载所有实体的计数
|
||||
useEffect(() => {
|
||||
if (!pluginId || entities.length === 0) return;
|
||||
const abortController = new AbortController();
|
||||
|
||||
async function loadAllCounts() {
|
||||
const results: EntityStat[] = [];
|
||||
for (const entity of entities) {
|
||||
if (abortController.signal.aborted) return;
|
||||
try {
|
||||
const count = await countPluginData(pluginId!, entity.name);
|
||||
if (abortController.signal.aborted) return;
|
||||
const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE;
|
||||
results.push({
|
||||
name: entity.name,
|
||||
displayName: entity.display_name || entity.name,
|
||||
count,
|
||||
icon: ENTITY_ICONS[entity.name] || <DashboardOutlined />,
|
||||
gradient: palette.gradient,
|
||||
iconBg: palette.iconBg,
|
||||
});
|
||||
} catch {
|
||||
const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE;
|
||||
results.push({
|
||||
name: entity.name,
|
||||
displayName: entity.display_name || entity.name,
|
||||
count: 0,
|
||||
icon: ENTITY_ICONS[entity.name] || <DashboardOutlined />,
|
||||
gradient: palette.gradient,
|
||||
iconBg: palette.iconBg,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!abortController.signal.aborted) {
|
||||
setEntityStats(results);
|
||||
}
|
||||
}
|
||||
|
||||
loadAllCounts();
|
||||
return () => abortController.abort();
|
||||
}, [pluginId, entities]);
|
||||
|
||||
// 当前实体的聚合数据
|
||||
const loadData = useCallback(async () => {
|
||||
if (!pluginId || !selectedEntity || filterableFields.length === 0) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const totalCount = entityStats.find((s) => s.name === selectedEntity)?.count ?? 0;
|
||||
const fieldResults: FieldBreakdown[] = [];
|
||||
|
||||
for (const field of filterableFields) {
|
||||
if (abortController.signal.aborted) return;
|
||||
try {
|
||||
const items = await aggregatePluginData(pluginId!, selectedEntity!, field.name);
|
||||
if (abortController.signal.aborted) return;
|
||||
fieldResults.push({
|
||||
fieldName: field.name,
|
||||
displayName: field.display_name || field.name,
|
||||
items,
|
||||
});
|
||||
} catch {
|
||||
// 单个字段聚合失败不影响其他字段
|
||||
}
|
||||
}
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
setBreakdowns(fieldResults);
|
||||
}
|
||||
} catch {
|
||||
setError('统计数据加载失败');
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) setLoading(false);
|
||||
}
|
||||
|
||||
return () => abortController.abort();
|
||||
}, [pluginId, selectedEntity, filterableFields, entityStats]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = loadData();
|
||||
return () => {
|
||||
cleanup?.then((fn) => fn?.()).catch(() => {});
|
||||
};
|
||||
}, [loadData]);
|
||||
|
||||
// 当前选中实体的总数
|
||||
const currentTotal = useMemo(
|
||||
() => entityStats.find((s) => s.name === selectedEntity)?.count ?? 0,
|
||||
[entityStats, selectedEntity],
|
||||
);
|
||||
|
||||
// 当前实体的色板
|
||||
const currentPalette = useMemo(
|
||||
() => ENTITY_PALETTE[selectedEntity] || DEFAULT_PALETTE,
|
||||
[selectedEntity],
|
||||
);
|
||||
|
||||
// ── 渲染 ──
|
||||
|
||||
if (schemaLoading) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<SkeletonStatCard key={i} delay={getDelayClass(i)} />
|
||||
))}
|
||||
</Row>
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 24 }}>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<SkeletonBreakdownCard key={i} index={i} />
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 页面标题 */}
|
||||
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: isDark ? '#F1F5F9' : '#0F172A',
|
||||
margin: '0 0 4px',
|
||||
letterSpacing: '-0.5px',
|
||||
}}
|
||||
>
|
||||
统计概览
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: isDark ? '#94A3B8' : '#475569',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
CRM 数据全景视图,实时掌握业务动态
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedEntity || undefined}
|
||||
style={{ width: 160 }}
|
||||
options={entities.map((e) => ({
|
||||
label: e.display_name || e.name,
|
||||
value: e.name,
|
||||
}))}
|
||||
onChange={setSelectedEntity}
|
||||
aria-label="选择实体类型"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 顶部统计卡片 */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
{loading && entityStats.length === 0
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<SkeletonStatCard key={i} delay={getDelayClass(i)} />
|
||||
))
|
||||
: entityStats.map((stat, i) => (
|
||||
<StatCard
|
||||
key={stat.name}
|
||||
stat={stat}
|
||||
loading={loading}
|
||||
delay={getDelayClass(i)}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 分组统计区域 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="erp-section-header">
|
||||
<DashboardOutlined
|
||||
className="erp-section-icon"
|
||||
style={{ color: currentPalette.tagColor === 'purple' ? '#4F46E5' : '#3B82F6' }}
|
||||
/>
|
||||
<span className="erp-section-title">
|
||||
{currentEntity?.display_name || selectedEntity} 数据分布
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 12,
|
||||
color: 'var(--erp-text-tertiary)',
|
||||
}}
|
||||
>
|
||||
共 {currentTotal.toLocaleString()} 条记录
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && breakdowns.length === 0 ? (
|
||||
<Row gutter={[16, 16]}>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<SkeletonBreakdownCard key={i} index={i} />
|
||||
))}
|
||||
</Row>
|
||||
) : breakdowns.length > 0 ? (
|
||||
<Row gutter={[16, 16]}>
|
||||
{breakdowns.map((bd, i) => (
|
||||
<BreakdownCard
|
||||
key={bd.fieldName}
|
||||
breakdown={bd}
|
||||
totalCount={currentTotal}
|
||||
palette={currentPalette}
|
||||
index={i}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
) : (
|
||||
<div className="erp-content-card" style={{ textAlign: 'center', padding: '48px 24px' }}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
filterableFields.length === 0
|
||||
? '当前实体无可筛选项,暂无分布数据'
|
||||
: '暂无数据'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: '12px 16px',
|
||||
borderRadius: 8,
|
||||
background: isDark ? 'rgba(220, 38, 38, 0.1)' : '#FEF2F2',
|
||||
color: isDark ? '#FCA5A5' : '#991B1B',
|
||||
fontSize: 13,
|
||||
}}
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1080
apps/web/src/pages/PluginGraphPage.tsx
Normal file
1080
apps/web/src/pages/PluginGraphPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
82
apps/web/src/pages/PluginTabsPage.tsx
Normal file
82
apps/web/src/pages/PluginTabsPage.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Tabs, Spin, message } from 'antd';
|
||||
import { getPluginSchema, type PluginPageSchema, type PluginSchemaResponse } from '../api/plugins';
|
||||
import PluginCRUDPage from './PluginCRUDPage';
|
||||
import { PluginTreePage } from './PluginTreePage';
|
||||
|
||||
/**
|
||||
* 插件 Tabs 页面 — 通过路由参数自加载 schema
|
||||
* 路由: /plugins/:pluginId/tabs/:pageLabel
|
||||
*/
|
||||
export function PluginTabsPage() {
|
||||
const { pluginId, pageLabel } = useParams<{ pluginId: string; pageLabel: string }>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tabs, setTabs] = useState<PluginPageSchema[]>([]);
|
||||
const [activeKey, setActiveKey] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginId || !pageLabel) return;
|
||||
const abortController = new AbortController();
|
||||
|
||||
async function loadSchema() {
|
||||
try {
|
||||
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
|
||||
const pages = schema.ui?.pages || [];
|
||||
const tabsPage = pages.find(
|
||||
(p): p is PluginPageSchema & { type: 'tabs' } =>
|
||||
p.type === 'tabs' && p.label === pageLabel,
|
||||
);
|
||||
if (tabsPage && 'tabs' in tabsPage) {
|
||||
setTabs(tabsPage.tabs);
|
||||
const firstLabel = tabsPage.tabs.find((t) => 'label' in t)?.label || '';
|
||||
setActiveKey(firstLabel);
|
||||
}
|
||||
} catch {
|
||||
message.warning('Schema 加载失败,部分功能不可用');
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadSchema();
|
||||
return () => abortController.abort();
|
||||
}, [pluginId, pageLabel]);
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>;
|
||||
}
|
||||
|
||||
const renderTabContent = (tab: PluginPageSchema) => {
|
||||
if (tab.type === 'crud') {
|
||||
return (
|
||||
<PluginCRUDPage
|
||||
pluginIdOverride={pluginId}
|
||||
entityOverride={tab.entity}
|
||||
enableViews={tab.enable_views}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tab.type === 'tree') {
|
||||
return (
|
||||
<PluginTreePage
|
||||
pluginIdOverride={pluginId}
|
||||
entityOverride={tab.entity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <div>不支持的页面类型: {tab.type}</div>;
|
||||
};
|
||||
|
||||
const items = tabs.map((tab) => ({
|
||||
key: 'label' in tab ? tab.label : '',
|
||||
label: 'label' in tab ? tab.label : '',
|
||||
children: renderTabContent(tab),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Tabs activeKey={activeKey} onChange={setActiveKey} items={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
apps/web/src/pages/PluginTreePage.tsx
Normal file
187
apps/web/src/pages/PluginTreePage.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Tree, Descriptions, Card, Empty, Spin, message } from 'antd';
|
||||
import type { TreeProps } from 'antd';
|
||||
import { listPluginData, type PluginDataRecord } from '../api/pluginData';
|
||||
import {
|
||||
getPluginSchema,
|
||||
type PluginFieldSchema,
|
||||
type PluginPageSchema,
|
||||
type PluginSchemaResponse,
|
||||
} from '../api/plugins';
|
||||
|
||||
interface TreeNode {
|
||||
key: string;
|
||||
title: string;
|
||||
children: TreeNode[];
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PluginTreePageProps {
|
||||
pluginIdOverride?: string;
|
||||
entityOverride?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件树形页面 — 通过路由参数自加载 schema
|
||||
* 路由: /plugins/:pluginId/tree/:entityName
|
||||
* 也支持通过 props 覆盖(用于 tabs 内嵌)
|
||||
*/
|
||||
export function PluginTreePage({ pluginIdOverride, entityOverride }: PluginTreePageProps = {}) {
|
||||
const routeParams = useParams<{ pluginId: string; entityName: string }>();
|
||||
const pluginId = pluginIdOverride || routeParams.pluginId || '';
|
||||
const entityName = entityOverride || routeParams.entityName || '';
|
||||
const [records, setRecords] = useState<PluginDataRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedNode, setSelectedNode] = useState<TreeNode | null>(null);
|
||||
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
|
||||
const [treeConfig, setTreeConfig] = useState<{
|
||||
idField: string;
|
||||
parentField: string;
|
||||
labelField: string;
|
||||
} | null>(null);
|
||||
|
||||
// 加载 schema
|
||||
useEffect(() => {
|
||||
if (!pluginId || !entityName) return;
|
||||
const abortController = new AbortController();
|
||||
|
||||
async function loadSchema() {
|
||||
try {
|
||||
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
const entity = schema.entities?.find((e) => e.name === entityName);
|
||||
if (entity) {
|
||||
setFields(entity.fields);
|
||||
}
|
||||
|
||||
const pages = schema.ui?.pages || [];
|
||||
const treePage = pages.find(
|
||||
(p): p is PluginPageSchema & { type: 'tree'; entity: string; id_field: string; parent_field: string; label_field: string } =>
|
||||
p.type === 'tree' && p.entity === entityName,
|
||||
);
|
||||
if (treePage) {
|
||||
setTreeConfig({
|
||||
idField: treePage.id_field,
|
||||
parentField: treePage.parent_field,
|
||||
labelField: treePage.label_field,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
message.warning('Schema 加载失败,部分功能不可用');
|
||||
}
|
||||
}
|
||||
|
||||
loadSchema();
|
||||
return () => abortController.abort();
|
||||
}, [pluginId, entityName]);
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
if (!pluginId || !entityName) return;
|
||||
const abortController = new AbortController();
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true);
|
||||
try {
|
||||
let allRecords: PluginDataRecord[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
if (abortController.signal.aborted) return;
|
||||
const result = await listPluginData(pluginId!, entityName!, page, 100);
|
||||
allRecords = [...allRecords, ...result.data];
|
||||
hasMore = result.data.length === 100 && allRecords.length < result.total;
|
||||
page++;
|
||||
}
|
||||
if (!abortController.signal.aborted) {
|
||||
setRecords(allRecords);
|
||||
}
|
||||
} catch {
|
||||
message.warning('数据加载失败');
|
||||
}
|
||||
if (!abortController.signal.aborted) setLoading(false);
|
||||
}
|
||||
loadAll();
|
||||
return () => abortController.abort();
|
||||
}, [pluginId, entityName]);
|
||||
|
||||
const idField = treeConfig?.idField || 'id';
|
||||
const parentField = treeConfig?.parentField || 'parent_id';
|
||||
const labelField = treeConfig?.labelField || fields[1]?.name || 'name';
|
||||
|
||||
// 构建树结构
|
||||
const treeData = useMemo(() => {
|
||||
const nodeMap = new Map<string, TreeNode>();
|
||||
const rootNodes: TreeNode[] = [];
|
||||
|
||||
for (const record of records) {
|
||||
const data = record.data;
|
||||
const key = String(data[idField] || record.id);
|
||||
const title = String(data[labelField] || '未命名');
|
||||
nodeMap.set(key, {
|
||||
key,
|
||||
title,
|
||||
children: [],
|
||||
raw: { ...data, _id: record.id, _version: record.version },
|
||||
});
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
const data = record.data;
|
||||
const key = String(data[idField] || record.id);
|
||||
const parentKey = data[parentField] ? String(data[parentField]) : null;
|
||||
const node = nodeMap.get(key)!;
|
||||
|
||||
if (parentKey && nodeMap.has(parentKey)) {
|
||||
nodeMap.get(parentKey)!.children.push(node);
|
||||
} else {
|
||||
rootNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return rootNodes;
|
||||
}, [records, idField, parentField, labelField]);
|
||||
|
||||
const onSelect: TreeProps['onSelect'] = (selectedKeys, info) => {
|
||||
if (selectedKeys.length > 0) {
|
||||
setSelectedNode(info.node as unknown as TreeNode);
|
||||
} else {
|
||||
setSelectedNode(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', gap: 16 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Card title={(entityName || '') + ' 层级'} size="small">
|
||||
{treeData.length === 0 ? (
|
||||
<Empty description="暂无数据" />
|
||||
) : (
|
||||
<Tree showLine defaultExpandAll treeData={treeData} onSelect={onSelect} />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Card title="节点详情" size="small">
|
||||
{selectedNode ? (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
{fields.map((field) => (
|
||||
<Descriptions.Item key={field.name} label={field.display_name || field.name}>
|
||||
{String(selectedNode.raw[field.name] ?? '-')}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
) : (
|
||||
<Empty description="点击左侧节点查看详情" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
import { create } from 'zustand';
|
||||
import type { PluginInfo, PluginStatus } from '../api/plugins';
|
||||
import { listPlugins } from '../api/plugins';
|
||||
import type { PluginInfo, PluginStatus, PluginPageSchema, PluginSchemaResponse } from '../api/plugins';
|
||||
import { listPlugins, getPluginSchema } from '../api/plugins';
|
||||
|
||||
export interface PluginMenuItem {
|
||||
key: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
pluginId: string;
|
||||
entity: string;
|
||||
menuGroup?: string;
|
||||
entity?: string;
|
||||
pageType: 'crud' | 'tree' | 'tabs' | 'detail' | 'graph' | 'dashboard';
|
||||
}
|
||||
|
||||
export interface PluginMenuGroup {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
items: PluginMenuItem[];
|
||||
}
|
||||
|
||||
interface PluginStore {
|
||||
plugins: PluginInfo[];
|
||||
loading: boolean;
|
||||
pluginMenuItems: PluginMenuItem[];
|
||||
pluginMenuGroups: PluginMenuGroup[];
|
||||
schemaCache: Record<string, PluginSchemaResponse>;
|
||||
fetchPlugins: (page?: number, status?: PluginStatus) => Promise<void>;
|
||||
refreshMenuItems: () => void;
|
||||
}
|
||||
@@ -23,12 +31,42 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
|
||||
plugins: [],
|
||||
loading: false,
|
||||
pluginMenuItems: [],
|
||||
pluginMenuGroups: [],
|
||||
schemaCache: {},
|
||||
|
||||
fetchPlugins: async (page = 1, status?: PluginStatus) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const result = await listPlugins(page, 100, status);
|
||||
set({ plugins: result.data });
|
||||
|
||||
// 先基于 entities 生成回退菜单,确保侧边栏快速渲染
|
||||
get().refreshMenuItems();
|
||||
|
||||
// 并行加载所有运行中插件的 schema,完成后更新菜单
|
||||
const activePlugins = result.data.filter(
|
||||
(p) => p.status === 'running' || p.status === 'enabled'
|
||||
);
|
||||
if (activePlugins.length === 0) return;
|
||||
|
||||
const entries = await Promise.allSettled(
|
||||
activePlugins.map(async (plugin) => {
|
||||
try {
|
||||
const schema = await getPluginSchema(plugin.id) as PluginSchemaResponse;
|
||||
return [plugin.id, schema] as const;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const schemas: Record<string, PluginSchemaResponse> = { ...get().schemaCache };
|
||||
for (const entry of entries) {
|
||||
if (entry.status === 'fulfilled' && entry.value) {
|
||||
schemas[entry.value[0]] = entry.value[1];
|
||||
}
|
||||
}
|
||||
set({ schemaCache: schemas });
|
||||
get().refreshMenuItems();
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
@@ -36,24 +74,96 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
|
||||
},
|
||||
|
||||
refreshMenuItems: () => {
|
||||
const { plugins } = get();
|
||||
const { plugins, schemaCache } = get();
|
||||
const items: PluginMenuItem[] = [];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.status !== 'running' && plugin.status !== 'enabled') continue;
|
||||
|
||||
for (const entity of plugin.entities) {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/${entity.name}`,
|
||||
icon: 'AppstoreOutlined',
|
||||
label: entity.display_name || entity.name,
|
||||
pluginId: plugin.id,
|
||||
entity: entity.name,
|
||||
menuGroup: undefined,
|
||||
});
|
||||
const schema = schemaCache[plugin.id];
|
||||
const pages = (schema as { ui?: { pages: PluginPageSchema[] } })?.ui?.pages;
|
||||
|
||||
if (pages && pages.length > 0) {
|
||||
for (const page of pages) {
|
||||
if (page.type === 'tabs') {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/tabs/${encodeURIComponent(page.label)}`,
|
||||
icon: page.icon || 'AppstoreOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
pageType: 'tabs' as const,
|
||||
});
|
||||
} else if (page.type === 'tree') {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/tree/${page.entity}`,
|
||||
icon: page.icon || 'ApartmentOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
entity: page.entity,
|
||||
pageType: 'tree' as const,
|
||||
});
|
||||
} else if (page.type === 'crud') {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/${page.entity}`,
|
||||
icon: page.icon || 'TableOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
entity: page.entity,
|
||||
pageType: 'crud' as const,
|
||||
});
|
||||
} else if (page.type === 'graph') {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/graph/${page.entity}`,
|
||||
icon: 'ApartmentOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
entity: page.entity,
|
||||
pageType: 'graph' as const,
|
||||
});
|
||||
} else if (page.type === 'dashboard') {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/dashboard`,
|
||||
icon: 'DashboardOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
pageType: 'dashboard' as const,
|
||||
});
|
||||
}
|
||||
// detail 类型不生成菜单项
|
||||
}
|
||||
} else {
|
||||
// 回退:从 entities 生成菜单
|
||||
for (const entity of plugin.entities) {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/${entity.name}`,
|
||||
icon: 'AppstoreOutlined',
|
||||
label: entity.display_name || entity.name,
|
||||
pluginId: plugin.id,
|
||||
entity: entity.name,
|
||||
pageType: 'crud',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set({ pluginMenuItems: items });
|
||||
|
||||
// 按 pluginId 分组生成三级菜单(复用上方已解构的 plugins)
|
||||
const groupMap = new Map<string, PluginMenuItem[]>();
|
||||
for (const item of items) {
|
||||
const list = groupMap.get(item.pluginId) || [];
|
||||
list.push(item);
|
||||
groupMap.set(item.pluginId, list);
|
||||
}
|
||||
const groups: PluginMenuGroup[] = [];
|
||||
for (const [pluginId, groupItems] of groupMap) {
|
||||
const plugin = plugins.find((p) => p.id === pluginId);
|
||||
groups.push({
|
||||
pluginId,
|
||||
pluginName: plugin?.name || pluginId,
|
||||
items: groupItems,
|
||||
});
|
||||
}
|
||||
set({ pluginMenuGroups: groups });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -8,4 +8,4 @@ pub mod rbac;
|
||||
pub mod types;
|
||||
|
||||
// 便捷导出
|
||||
pub use module::{ModuleContext, ModuleType};
|
||||
pub use module::{ModuleContext, ModuleType, PermissionDescriptor};
|
||||
|
||||
@@ -7,6 +7,22 @@ use uuid::Uuid;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::events::EventBus;
|
||||
|
||||
/// 权限描述符,用于模块声明自己需要的权限。
|
||||
///
|
||||
/// 各业务模块通过 `ErpModule::permissions()` 返回此列表,
|
||||
/// 由 erp-server 在启动时统一注册到权限表。
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PermissionDescriptor {
|
||||
/// 权限编码,全局唯一,格式建议 `{模块}.{动作}` 如 `plugin.admin`
|
||||
pub code: String,
|
||||
/// 权限显示名称
|
||||
pub name: String,
|
||||
/// 权限描述
|
||||
pub description: String,
|
||||
/// 所属模块名称
|
||||
pub module: String,
|
||||
}
|
||||
|
||||
/// 模块类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ModuleType {
|
||||
@@ -90,6 +106,13 @@ pub trait ErpModule: Send + Sync {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 返回此模块需要注册的权限列表。
|
||||
///
|
||||
/// 默认返回空列表,有权限需求的模块(如 plugin)可覆写此方法。
|
||||
fn permissions(&self) -> Vec<PermissionDescriptor> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Downcast support: return `self` as `&dyn Any` for concrete type access.
|
||||
///
|
||||
/// This allows the server crate to retrieve module-specific methods
|
||||
|
||||
13
crates/erp-plugin-crm/Cargo.toml
Normal file
13
crates/erp-plugin-crm/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "erp-plugin-crm"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "CRM 客户管理插件 — ERP 平台第一个行业插件"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.55"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
425
crates/erp-plugin-crm/plugin.toml
Normal file
425
crates/erp-plugin-crm/plugin.toml
Normal file
@@ -0,0 +1,425 @@
|
||||
[metadata]
|
||||
id = "erp-crm"
|
||||
name = "CRM"
|
||||
version = "0.1.0"
|
||||
description = "客户关系管理插件 — ERP 平台第一个行业插件"
|
||||
author = "ERP Team"
|
||||
min_platform_version = "0.1.0"
|
||||
|
||||
# ── 权限声明 ──
|
||||
|
||||
[[permissions]]
|
||||
code = "customer.list"
|
||||
name = "查看客户"
|
||||
description = "查看客户列表和详情"
|
||||
|
||||
[[permissions]]
|
||||
code = "customer.manage"
|
||||
name = "管理客户"
|
||||
description = "创建、编辑、删除客户"
|
||||
|
||||
[[permissions]]
|
||||
code = "contact.list"
|
||||
name = "查看联系人"
|
||||
|
||||
[[permissions]]
|
||||
code = "contact.manage"
|
||||
name = "管理联系人"
|
||||
|
||||
[[permissions]]
|
||||
code = "communication.list"
|
||||
name = "查看沟通记录"
|
||||
|
||||
[[permissions]]
|
||||
code = "communication.manage"
|
||||
name = "管理沟通记录"
|
||||
|
||||
[[permissions]]
|
||||
code = "tag.manage"
|
||||
name = "管理客户标签"
|
||||
|
||||
[[permissions]]
|
||||
code = "relationship.list"
|
||||
name = "查看客户关系"
|
||||
|
||||
[[permissions]]
|
||||
code = "relationship.manage"
|
||||
name = "管理客户关系"
|
||||
|
||||
# ── 实体定义 ──
|
||||
|
||||
[[schema.entities]]
|
||||
name = "customer"
|
||||
display_name = "客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "code"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "客户编码"
|
||||
unique = true
|
||||
searchable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "name"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "客户名称"
|
||||
searchable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_type"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "客户类型"
|
||||
ui_widget = "select"
|
||||
filterable = true
|
||||
options = [
|
||||
{ label = "企业", value = "enterprise" },
|
||||
{ label = "个人", value = "personal" }
|
||||
]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "industry"
|
||||
field_type = "string"
|
||||
display_name = "行业"
|
||||
filterable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "region"
|
||||
field_type = "string"
|
||||
display_name = "地区"
|
||||
filterable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "source"
|
||||
field_type = "string"
|
||||
display_name = "来源"
|
||||
ui_widget = "select"
|
||||
options = [
|
||||
{ label = "推荐", value = "referral" },
|
||||
{ label = "广告", value = "ad" },
|
||||
{ label = "展会", value = "exhibition" },
|
||||
{ label = "主动联系", value = "outreach" },
|
||||
{ label = "其他", value = "other" }
|
||||
]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "level"
|
||||
field_type = "string"
|
||||
display_name = "等级"
|
||||
ui_widget = "select"
|
||||
filterable = true
|
||||
options = [
|
||||
{ label = "潜在客户", value = "potential" },
|
||||
{ label = "普通客户", value = "normal" },
|
||||
{ label = "VIP", value = "vip" },
|
||||
{ label = "SVIP", value = "svip" }
|
||||
]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "status"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "状态"
|
||||
ui_widget = "select"
|
||||
filterable = true
|
||||
options = [
|
||||
{ label = "活跃", value = "active" },
|
||||
{ label = "停用", value = "inactive" },
|
||||
{ label = "黑名单", value = "blacklist" }
|
||||
]
|
||||
|
||||
[[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 == 'personal'"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "parent_id"
|
||||
field_type = "uuid"
|
||||
display_name = "上级客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "website"
|
||||
field_type = "string"
|
||||
display_name = "网站"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "address"
|
||||
field_type = "string"
|
||||
display_name = "地址"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "remark"
|
||||
field_type = "string"
|
||||
display_name = "备注"
|
||||
ui_widget = "textarea"
|
||||
|
||||
[[schema.entities]]
|
||||
name = "contact"
|
||||
display_name = "联系人"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "所属客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "name"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "姓名"
|
||||
searchable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "position"
|
||||
field_type = "string"
|
||||
display_name = "职务"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "department"
|
||||
field_type = "string"
|
||||
display_name = "部门"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "phone"
|
||||
field_type = "string"
|
||||
display_name = "手机号"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "email"
|
||||
field_type = "string"
|
||||
display_name = "邮箱"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "wechat"
|
||||
field_type = "string"
|
||||
display_name = "微信号"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "is_primary"
|
||||
field_type = "boolean"
|
||||
display_name = "主联系人"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "remark"
|
||||
field_type = "string"
|
||||
display_name = "备注"
|
||||
|
||||
[[schema.entities]]
|
||||
name = "communication"
|
||||
display_name = "沟通记录"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "关联客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "contact_id"
|
||||
field_type = "uuid"
|
||||
display_name = "关联联系人"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "type"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "类型"
|
||||
ui_widget = "select"
|
||||
filterable = true
|
||||
options = [
|
||||
{ label = "电话", value = "phone" },
|
||||
{ label = "邮件", value = "email" },
|
||||
{ label = "会议", value = "meeting" },
|
||||
{ label = "拜访", value = "visit" },
|
||||
{ label = "其他", value = "other" }
|
||||
]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "subject"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "主题"
|
||||
searchable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "content"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "内容"
|
||||
ui_widget = "textarea"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "occurred_at"
|
||||
field_type = "date_time"
|
||||
required = true
|
||||
display_name = "沟通时间"
|
||||
sortable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "next_follow_up"
|
||||
field_type = "date"
|
||||
display_name = "下次跟进日期"
|
||||
|
||||
[[schema.entities]]
|
||||
name = "customer_tag"
|
||||
display_name = "客户标签"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "关联客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "tag_name"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "标签名称"
|
||||
searchable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "tag_category"
|
||||
field_type = "string"
|
||||
display_name = "标签分类"
|
||||
ui_widget = "select"
|
||||
options = [
|
||||
{ label = "行业", value = "industry" },
|
||||
{ label = "地区", value = "region" },
|
||||
{ label = "来源", value = "source" },
|
||||
{ label = "自定义", value = "custom" }
|
||||
]
|
||||
|
||||
[[schema.entities]]
|
||||
name = "customer_relationship"
|
||||
display_name = "客户关系"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "from_customer_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "源客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "to_customer_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "目标客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "relationship_type"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "关系类型"
|
||||
ui_widget = "select"
|
||||
filterable = true
|
||||
options = [
|
||||
{ label = "母子公司", value = "parent_child" },
|
||||
{ label = "兄弟公司", value = "sibling" },
|
||||
{ label = "合作伙伴", value = "partner" },
|
||||
{ label = "供应商", value = "supplier" },
|
||||
{ label = "竞争对手", value = "competitor" }
|
||||
]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "description"
|
||||
field_type = "string"
|
||||
display_name = "关系描述"
|
||||
|
||||
# ── 页面声明 ──
|
||||
|
||||
[[ui.pages]]
|
||||
type = "tabs"
|
||||
label = "客户管理"
|
||||
icon = "team"
|
||||
|
||||
[[ui.pages.tabs]]
|
||||
label = "客户列表"
|
||||
type = "crud"
|
||||
entity = "customer"
|
||||
enable_search = true
|
||||
enable_views = ["table"]
|
||||
|
||||
[[ui.pages.tabs]]
|
||||
label = "客户层级"
|
||||
type = "tree"
|
||||
entity = "customer"
|
||||
id_field = "id"
|
||||
parent_field = "parent_id"
|
||||
label_field = "name"
|
||||
|
||||
[[ui.pages]]
|
||||
type = "detail"
|
||||
entity = "customer"
|
||||
label = "客户详情"
|
||||
|
||||
[[ui.pages.sections]]
|
||||
type = "fields"
|
||||
label = "基本信息"
|
||||
fields = ["code", "name", "customer_type", "industry", "region", "level", "status", "credit_code", "id_number", "website", "address", "remark"]
|
||||
|
||||
[[ui.pages.sections]]
|
||||
type = "crud"
|
||||
label = "联系人"
|
||||
entity = "contact"
|
||||
filter_field = "customer_id"
|
||||
|
||||
[[ui.pages.sections]]
|
||||
type = "crud"
|
||||
label = "沟通记录"
|
||||
entity = "communication"
|
||||
filter_field = "customer_id"
|
||||
enable_views = ["table", "timeline"]
|
||||
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
entity = "contact"
|
||||
label = "联系人"
|
||||
icon = "user"
|
||||
enable_search = true
|
||||
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
entity = "communication"
|
||||
label = "沟通记录"
|
||||
icon = "message"
|
||||
enable_search = true
|
||||
enable_views = ["table", "timeline"]
|
||||
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
entity = "customer_tag"
|
||||
label = "标签管理"
|
||||
icon = "tags"
|
||||
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
entity = "customer_relationship"
|
||||
label = "客户关系"
|
||||
icon = "apartment"
|
||||
|
||||
[[ui.pages]]
|
||||
type = "graph"
|
||||
entity = "customer"
|
||||
label = "关系图谱"
|
||||
icon = "apartment"
|
||||
relationship_entity = "customer_relationship"
|
||||
source_field = "from_customer_id"
|
||||
target_field = "to_customer_id"
|
||||
edge_label_field = "relationship_type"
|
||||
node_label_field = "name"
|
||||
|
||||
[[ui.pages]]
|
||||
type = "dashboard"
|
||||
label = "统计概览"
|
||||
icon = "DashboardOutlined"
|
||||
29
crates/erp-plugin-crm/src/lib.rs
Normal file
29
crates/erp-plugin-crm/src/lib.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
//! CRM 客户管理插件 — WASM Guest 实现
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "../erp-plugin-prototype/wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
use crate::exports::erp::plugin::plugin_api::Guest;
|
||||
|
||||
struct CrmPlugin;
|
||||
|
||||
impl Guest for CrmPlugin {
|
||||
fn init() -> Result<(), String> {
|
||||
// CRM 插件初始化:当前无需创建默认数据
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(_tenant_id: String) -> Result<(), String> {
|
||||
// 为新租户创建 CRM 默认数据:当前无需创建默认客户
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> {
|
||||
// CRM V1: 无事件处理
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
export!(CrmPlugin);
|
||||
@@ -22,3 +22,4 @@ axum = { workspace = true }
|
||||
utoipa = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
base64 = "0.22"
|
||||
|
||||
@@ -29,5 +29,39 @@ pub struct UpdatePluginDataReq {
|
||||
pub struct PluginDataListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
/// Base64 编码的游标(用于 Keyset 分页)
|
||||
pub cursor: Option<String>,
|
||||
pub search: Option<String>,
|
||||
/// JSON 格式过滤: {"field":"value"}
|
||||
pub filter: Option<String>,
|
||||
pub sort_by: Option<String>,
|
||||
/// "asc" or "desc"
|
||||
pub sort_order: Option<String>,
|
||||
}
|
||||
|
||||
/// 聚合查询响应项
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AggregateItem {
|
||||
/// 分组键(字段值)
|
||||
pub key: String,
|
||||
/// 计数
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// 聚合查询参数
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
|
||||
pub struct AggregateQueryParams {
|
||||
/// 分组字段名
|
||||
pub group_by: String,
|
||||
/// JSON 格式过滤: {"field":"value"}
|
||||
pub filter: Option<String>,
|
||||
}
|
||||
|
||||
/// 统计查询参数
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
|
||||
pub struct CountQueryParams {
|
||||
/// 搜索关键词
|
||||
pub search: Option<String>,
|
||||
/// JSON 格式过滤: {"field":"value"}
|
||||
pub filter: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, FromQueryResult, QueryFilter, Statement};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
use erp_core::error::{AppError, AppResult};
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
use crate::data_dto::PluginDataResp;
|
||||
use crate::dynamic_table::DynamicTableManager;
|
||||
use crate::entity::plugin;
|
||||
use crate::entity::plugin_entity;
|
||||
use crate::error::PluginError;
|
||||
use crate::manifest::PluginField;
|
||||
|
||||
pub struct PluginDataService;
|
||||
|
||||
/// 插件实体信息(合并查询减少 DB 调用)
|
||||
struct EntityInfo {
|
||||
table_name: String,
|
||||
schema_json: serde_json::Value,
|
||||
}
|
||||
|
||||
impl EntityInfo {
|
||||
fn fields(&self) -> AppResult<Vec<PluginField>> {
|
||||
let entity_def: crate::manifest::PluginEntity =
|
||||
serde_json::from_value(self.schema_json.clone())
|
||||
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||||
Ok(entity_def.fields)
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginDataService {
|
||||
/// 创建插件数据
|
||||
pub async fn create(
|
||||
@@ -22,9 +39,12 @@ impl PluginDataService {
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<PluginDataResp> {
|
||||
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let fields = info.fields()?;
|
||||
validate_data(&data, &fields)?;
|
||||
|
||||
let (sql, values) =
|
||||
DynamicTableManager::build_insert_sql(&table_name, tenant_id, operator_id, &data);
|
||||
DynamicTableManager::build_insert_sql(&info.table_name, tenant_id, operator_id, &data);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct InsertResult {
|
||||
@@ -53,7 +73,7 @@ impl PluginDataService {
|
||||
})
|
||||
}
|
||||
|
||||
/// 列表查询
|
||||
/// 列表查询(支持过滤/搜索/排序)
|
||||
pub async fn list(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
@@ -61,11 +81,30 @@ impl PluginDataService {
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
filter: Option<serde_json::Value>,
|
||||
search: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
sort_order: Option<String>,
|
||||
) -> AppResult<(Vec<PluginDataResp>, u64)> {
|
||||
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
|
||||
// 获取 searchable 字段列表
|
||||
let entity_fields = info.fields()?;
|
||||
let search_tuple = {
|
||||
let searchable: Vec<&str> = entity_fields
|
||||
.iter()
|
||||
.filter(|f| f.searchable == Some(true))
|
||||
.map(|f| f.name.as_str())
|
||||
.collect();
|
||||
match (searchable.is_empty(), &search) {
|
||||
(false, Some(kw)) => Some((searchable.join(","), kw.clone())),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
// Count
|
||||
let (count_sql, count_values) = DynamicTableManager::build_count_sql(&table_name, tenant_id);
|
||||
let (count_sql, count_values) =
|
||||
DynamicTableManager::build_count_sql(&info.table_name, tenant_id);
|
||||
#[derive(FromQueryResult)]
|
||||
struct CountResult {
|
||||
count: i64,
|
||||
@@ -81,8 +120,18 @@ impl PluginDataService {
|
||||
.unwrap_or(0);
|
||||
|
||||
// Query
|
||||
let offset = (page.saturating_sub(1)) * page_size;
|
||||
let (sql, values) = DynamicTableManager::build_query_sql(&table_name, tenant_id, page_size, offset);
|
||||
let offset = page.saturating_sub(1) * page_size;
|
||||
let (sql, values) = DynamicTableManager::build_filtered_query_sql(
|
||||
&info.table_name,
|
||||
tenant_id,
|
||||
page_size,
|
||||
offset,
|
||||
filter,
|
||||
search_tuple,
|
||||
sort_by,
|
||||
sort_order,
|
||||
)
|
||||
.map_err(|e| AppError::Validation(e))?;
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct DataRow {
|
||||
@@ -123,8 +172,8 @@ impl PluginDataService {
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<PluginDataResp> {
|
||||
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let (sql, values) = DynamicTableManager::build_get_by_id_sql(&table_name, id, tenant_id);
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let (sql, values) = DynamicTableManager::build_get_by_id_sql(&info.table_name, id, tenant_id);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct DataRow {
|
||||
@@ -142,7 +191,7 @@ impl PluginDataService {
|
||||
))
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| erp_core::error::AppError::NotFound("记录不存在".to_string()))?;
|
||||
.ok_or_else(|| AppError::NotFound("记录不存在".to_string()))?;
|
||||
|
||||
Ok(PluginDataResp {
|
||||
id: row.id.to_string(),
|
||||
@@ -165,9 +214,12 @@ impl PluginDataService {
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<PluginDataResp> {
|
||||
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let fields = info.fields()?;
|
||||
validate_data(&data, &fields)?;
|
||||
|
||||
let (sql, values) = DynamicTableManager::build_update_sql(
|
||||
&table_name,
|
||||
&info.table_name,
|
||||
id,
|
||||
tenant_id,
|
||||
operator_id,
|
||||
@@ -191,7 +243,7 @@ impl PluginDataService {
|
||||
))
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| erp_core::error::AppError::VersionMismatch)?;
|
||||
.ok_or_else(|| AppError::VersionMismatch)?;
|
||||
|
||||
Ok(PluginDataResp {
|
||||
id: result.id.to_string(),
|
||||
@@ -211,8 +263,8 @@ impl PluginDataService {
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<()> {
|
||||
let table_name = resolve_table_name(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let (sql, values) = DynamicTableManager::build_delete_sql(&table_name, id, tenant_id);
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let (sql, values) = DynamicTableManager::build_delete_sql(&info.table_name, id, tenant_id);
|
||||
|
||||
db.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
@@ -223,15 +275,128 @@ impl PluginDataService {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 统计记录数(支持过滤和搜索)
|
||||
pub async fn count(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
filter: Option<serde_json::Value>,
|
||||
search: Option<String>,
|
||||
) -> AppResult<u64> {
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
|
||||
let entity_fields = info.fields()?;
|
||||
let search_tuple = {
|
||||
let searchable: Vec<&str> = entity_fields
|
||||
.iter()
|
||||
.filter(|f| f.searchable == Some(true))
|
||||
.map(|f| f.name.as_str())
|
||||
.collect();
|
||||
match (searchable.is_empty(), &search) {
|
||||
(false, Some(kw)) => Some((searchable.join(","), kw.clone())),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
let (sql, values) = DynamicTableManager::build_filtered_count_sql(
|
||||
&info.table_name,
|
||||
tenant_id,
|
||||
filter,
|
||||
search_tuple,
|
||||
)
|
||||
.map_err(|e| AppError::Validation(e))?;
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct CountResult {
|
||||
count: i64,
|
||||
}
|
||||
|
||||
let result = CountResult::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.one(db)
|
||||
.await?
|
||||
.map(|r| r.count as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 聚合查询 — 按字段分组计数
|
||||
/// 返回 [(分组键, 计数), ...]
|
||||
pub async fn aggregate(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
group_by_field: &str,
|
||||
filter: Option<serde_json::Value>,
|
||||
) -> AppResult<Vec<(String, i64)>> {
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
|
||||
let (sql, values) = DynamicTableManager::build_aggregate_sql(
|
||||
&info.table_name,
|
||||
tenant_id,
|
||||
group_by_field,
|
||||
filter,
|
||||
)
|
||||
.map_err(|e| AppError::Validation(e))?;
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct AggRow {
|
||||
key: Option<String>,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
let rows = AggRow::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let result = rows
|
||||
.into_iter()
|
||||
.map(|r| (r.key.unwrap_or_default(), r.count))
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 plugin_entities 表解析 table_name(带租户隔离)
|
||||
async fn resolve_table_name(
|
||||
/// 从 plugins 表解析 manifest metadata.id(如 "erp-crm")
|
||||
pub async fn resolve_manifest_id(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<String> {
|
||||
let model = plugin::Entity::find()
|
||||
.filter(plugin::Column::Id.eq(plugin_id))
|
||||
.filter(plugin::Column::TenantId.eq(tenant_id))
|
||||
.filter(plugin::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("插件 {} 不存在", plugin_id)))?;
|
||||
|
||||
let manifest: crate::manifest::PluginManifest =
|
||||
serde_json::from_value(model.manifest_json)
|
||||
.map_err(|e| AppError::Internal(format!("解析插件 manifest 失败: {}", e)))?;
|
||||
|
||||
Ok(manifest.metadata.id)
|
||||
}
|
||||
|
||||
/// 从 plugin_entities 表获取实体完整信息(带租户隔离)
|
||||
async fn resolve_entity_info(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<String> {
|
||||
) -> AppResult<EntityInfo> {
|
||||
let entity = plugin_entity::Entity::find()
|
||||
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
|
||||
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||||
@@ -240,11 +405,25 @@ async fn resolve_table_name(
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
erp_core::error::AppError::NotFound(format!(
|
||||
"插件实体 {}/{} 不存在",
|
||||
plugin_id, entity_name
|
||||
))
|
||||
AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name))
|
||||
})?;
|
||||
|
||||
Ok(entity.table_name)
|
||||
Ok(EntityInfo {
|
||||
table_name: entity.table_name,
|
||||
schema_json: entity.schema_json,
|
||||
})
|
||||
}
|
||||
|
||||
/// 校验数据:检查 required 字段
|
||||
fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<()> {
|
||||
let obj = data.as_object().ok_or_else(|| {
|
||||
AppError::Validation("data 必须是 JSON 对象".to_string())
|
||||
})?;
|
||||
for field in fields {
|
||||
if field.required && !obj.contains_key(&field.name) {
|
||||
let label = field.display_name.as_deref().unwrap_or(&field.name);
|
||||
return Err(AppError::Validation(format!("字段 '{}' 不能为空", label)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,23 @@ use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::data_dto::{CreatePluginDataReq, PluginDataListParams, PluginDataResp, UpdatePluginDataReq};
|
||||
use crate::data_service::PluginDataService;
|
||||
use crate::data_dto::{
|
||||
AggregateItem, AggregateQueryParams, CountQueryParams, CreatePluginDataReq,
|
||||
PluginDataListParams, PluginDataResp, UpdatePluginDataReq,
|
||||
};
|
||||
use crate::data_service::{PluginDataService, resolve_manifest_id};
|
||||
use crate::state::PluginState;
|
||||
|
||||
/// 计算插件数据操作所需的权限码
|
||||
/// 格式:{manifest_id}.{entity}.{action},如 erp-crm.customer.list
|
||||
fn compute_permission_code(manifest_id: &str, entity_name: &str, action: &str) -> String {
|
||||
let action_suffix = match action {
|
||||
"list" | "get" => "list",
|
||||
_ => "manage",
|
||||
};
|
||||
format!("{}.{}.{}", manifest_id, entity_name, action_suffix)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}",
|
||||
@@ -32,11 +45,21 @@ where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
|
||||
if require_permission(&ctx, &fine_perm).is_err() {
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
}
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
|
||||
// 解析 filter JSON
|
||||
let filter: Option<serde_json::Value> = params
|
||||
.filter
|
||||
.as_ref()
|
||||
.and_then(|f| serde_json::from_str(f).ok());
|
||||
|
||||
let (items, total) = PluginDataService::list(
|
||||
plugin_id,
|
||||
&entity,
|
||||
@@ -44,6 +67,10 @@ where
|
||||
page,
|
||||
page_size,
|
||||
&state.db,
|
||||
filter,
|
||||
params.search,
|
||||
params.sort_by,
|
||||
params.sort_order,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -77,7 +104,11 @@ where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "create");
|
||||
if require_permission(&ctx, &fine_perm).is_err() {
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
}
|
||||
|
||||
let result = PluginDataService::create(
|
||||
plugin_id,
|
||||
@@ -112,7 +143,11 @@ where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "get");
|
||||
if require_permission(&ctx, &fine_perm).is_err() {
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
}
|
||||
|
||||
let result =
|
||||
PluginDataService::get_by_id(plugin_id, &entity, id, ctx.tenant_id, &state.db).await?;
|
||||
@@ -141,7 +176,11 @@ where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "update");
|
||||
if require_permission(&ctx, &fine_perm).is_err() {
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
}
|
||||
|
||||
let result = PluginDataService::update(
|
||||
plugin_id,
|
||||
@@ -178,7 +217,11 @@ where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "delete");
|
||||
if require_permission(&ctx, &fine_perm).is_err() {
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
}
|
||||
|
||||
PluginDataService::delete(
|
||||
plugin_id,
|
||||
@@ -192,3 +235,100 @@ where
|
||||
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}/count",
|
||||
params(CountQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<u64>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件数据"
|
||||
)]
|
||||
/// GET /api/v1/plugins/{plugin_id}/{entity}/count — 统计计数
|
||||
pub async fn count_plugin_data<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((plugin_id, entity)): Path<(Uuid, String)>,
|
||||
Query(params): Query<CountQueryParams>,
|
||||
) -> Result<Json<ApiResponse<u64>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
|
||||
if require_permission(&ctx, &fine_perm).is_err() {
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
}
|
||||
|
||||
// 解析 filter JSON
|
||||
let filter: Option<serde_json::Value> = params
|
||||
.filter
|
||||
.as_ref()
|
||||
.and_then(|f| serde_json::from_str(f).ok());
|
||||
|
||||
let total = PluginDataService::count(
|
||||
plugin_id,
|
||||
&entity,
|
||||
ctx.tenant_id,
|
||||
&state.db,
|
||||
filter,
|
||||
params.search,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(total)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}/aggregate",
|
||||
params(AggregateQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<AggregateItem>>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件数据"
|
||||
)]
|
||||
/// GET /api/v1/plugins/{plugin_id}/{entity}/aggregate — 聚合查询
|
||||
pub async fn aggregate_plugin_data<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((plugin_id, entity)): Path<(Uuid, String)>,
|
||||
Query(params): Query<AggregateQueryParams>,
|
||||
) -> Result<Json<ApiResponse<Vec<AggregateItem>>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
|
||||
if require_permission(&ctx, &fine_perm).is_err() {
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
}
|
||||
|
||||
// 解析 filter JSON
|
||||
let filter: Option<serde_json::Value> = params
|
||||
.filter
|
||||
.as_ref()
|
||||
.and_then(|f| serde_json::from_str(f).ok());
|
||||
|
||||
let rows = PluginDataService::aggregate(
|
||||
plugin_id,
|
||||
&entity,
|
||||
ctx.tenant_id,
|
||||
&state.db,
|
||||
¶ms.group_by,
|
||||
filter,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|(key, count)| AggregateItem { key, count })
|
||||
.collect();
|
||||
|
||||
Ok(Json(ApiResponse::ok(items)))
|
||||
}
|
||||
|
||||
@@ -199,7 +199,11 @@ where
|
||||
&state.db,
|
||||
&state.engine,
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Install failed");
|
||||
e
|
||||
})?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,14 @@ pub struct PluginField {
|
||||
pub display_name: Option<String>,
|
||||
pub ui_widget: Option<String>,
|
||||
pub options: Option<Vec<serde_json::Value>>,
|
||||
#[serde(default)]
|
||||
pub searchable: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub filterable: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub sortable: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub visible_when: Option<String>,
|
||||
}
|
||||
|
||||
/// 字段类型
|
||||
@@ -75,6 +83,56 @@ pub enum PluginFieldType {
|
||||
Decimal,
|
||||
}
|
||||
|
||||
impl PluginFieldType {
|
||||
/// Generated Column 的 SQL 类型
|
||||
pub fn generated_sql_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::String | Self::Json => "TEXT",
|
||||
Self::Integer => "INTEGER",
|
||||
Self::Float => "DOUBLE PRECISION",
|
||||
Self::Decimal => "NUMERIC",
|
||||
Self::Boolean => "BOOLEAN",
|
||||
Self::Date => "DATE",
|
||||
Self::DateTime => "TIMESTAMPTZ",
|
||||
Self::Uuid => "UUID",
|
||||
}
|
||||
}
|
||||
|
||||
/// Generated Column 的表达式
|
||||
pub fn generated_expr(&self, field_name: &str) -> String {
|
||||
match self {
|
||||
Self::String | Self::Json => format!("data->>'{}'", field_name),
|
||||
_ => format!("(data->>'{}')::{}", field_name, self.generated_sql_type()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 该类型是否适合生成 Generated Column
|
||||
pub fn supports_generated_column(&self) -> bool {
|
||||
!matches!(self, Self::Json)
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginField {
|
||||
/// 测试辅助:构造一个全默认值的 PluginField
|
||||
#[cfg(test)]
|
||||
pub fn default_for_field() -> Self {
|
||||
Self {
|
||||
name: String::new(),
|
||||
field_type: PluginFieldType::String,
|
||||
required: false,
|
||||
unique: false,
|
||||
default: None,
|
||||
display_name: None,
|
||||
ui_widget: None,
|
||||
options: None,
|
||||
searchable: None,
|
||||
filterable: None,
|
||||
sortable: None,
|
||||
visible_when: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 索引定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginIndex {
|
||||
@@ -93,19 +151,85 @@ pub struct PluginEvents {
|
||||
/// UI 页面配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginUi {
|
||||
pub pages: Vec<PluginPage>,
|
||||
pub pages: Vec<PluginPageType>,
|
||||
}
|
||||
|
||||
/// 插件页面定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginPage {
|
||||
pub route: String,
|
||||
pub entity: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub icon: String,
|
||||
#[serde(default)]
|
||||
pub menu_group: Option<String>,
|
||||
/// 插件页面类型(tagged enum,TOML 中通过 type 字段区分)
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum PluginPageType {
|
||||
#[serde(rename = "crud")]
|
||||
Crud {
|
||||
entity: String,
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
#[serde(default)]
|
||||
enable_search: Option<bool>,
|
||||
#[serde(default)]
|
||||
enable_views: Option<Vec<String>>,
|
||||
},
|
||||
#[serde(rename = "tree")]
|
||||
Tree {
|
||||
entity: String,
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
id_field: String,
|
||||
parent_field: String,
|
||||
label_field: String,
|
||||
},
|
||||
#[serde(rename = "detail")]
|
||||
Detail {
|
||||
entity: String,
|
||||
label: String,
|
||||
sections: Vec<PluginSection>,
|
||||
},
|
||||
#[serde(rename = "tabs")]
|
||||
Tabs {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
tabs: Vec<PluginPageType>,
|
||||
},
|
||||
#[serde(rename = "graph")]
|
||||
Graph {
|
||||
entity: String,
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
relationship_entity: String,
|
||||
source_field: String,
|
||||
target_field: String,
|
||||
edge_label_field: String,
|
||||
node_label_field: String,
|
||||
},
|
||||
#[serde(rename = "dashboard")]
|
||||
Dashboard {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 插件页面区段(用于 detail 页面类型)
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum PluginSection {
|
||||
#[serde(rename = "fields")]
|
||||
Fields {
|
||||
label: String,
|
||||
fields: Vec<String>,
|
||||
},
|
||||
#[serde(rename = "crud")]
|
||||
Crud {
|
||||
label: String,
|
||||
entity: String,
|
||||
#[serde(default)]
|
||||
filter_field: Option<String>,
|
||||
#[serde(default)]
|
||||
enable_views: Option<Vec<String>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 权限定义
|
||||
@@ -154,9 +278,85 @@ pub fn parse_manifest(toml_str: &str) -> PluginResult<PluginManifest> {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证页面类型配置
|
||||
if let Some(ui) = &manifest.ui {
|
||||
validate_pages(&ui.pages)?;
|
||||
}
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// 递归验证页面配置
|
||||
fn validate_pages(pages: &[PluginPageType]) -> PluginResult<()> {
|
||||
for page in pages {
|
||||
match page {
|
||||
PluginPageType::Crud { entity, .. } => {
|
||||
if entity.is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"crud page 的 entity 不能为空".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
PluginPageType::Tree {
|
||||
entity: _,
|
||||
label: _,
|
||||
icon: _,
|
||||
id_field,
|
||||
parent_field,
|
||||
label_field,
|
||||
} => {
|
||||
if id_field.is_empty() || parent_field.is_empty() || label_field.is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"tree page 的 id_field/parent_field/label_field 不能为空".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
PluginPageType::Detail { entity, sections, .. } => {
|
||||
if entity.is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"detail page 的 entity 不能为空".into(),
|
||||
));
|
||||
}
|
||||
if sections.is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"detail page 的 sections 不能为空".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
PluginPageType::Tabs { tabs, .. } => {
|
||||
if tabs.is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"tabs page 的 tabs 不能为空".into(),
|
||||
));
|
||||
}
|
||||
validate_pages(tabs)?;
|
||||
}
|
||||
PluginPageType::Graph {
|
||||
entity,
|
||||
relationship_entity,
|
||||
source_field,
|
||||
target_field,
|
||||
..
|
||||
} => {
|
||||
if entity.is_empty() || relationship_entity.is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"graph page 的 entity/relationship_entity 不能为空".into(),
|
||||
));
|
||||
}
|
||||
if source_field.is_empty() || target_field.is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"graph page 的 source_field/target_field 不能为空".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
PluginPageType::Dashboard { .. } => {
|
||||
// dashboard 无需额外验证
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -208,11 +408,10 @@ subscribe = ["workflow.task.completed", "order.*"]
|
||||
|
||||
[ui]
|
||||
[[ui.pages]]
|
||||
route = "/products"
|
||||
type = "crud"
|
||||
entity = "product"
|
||||
display_name = "商品管理"
|
||||
label = "商品管理"
|
||||
icon = "ShoppingOutlined"
|
||||
menu_group = "进销存"
|
||||
|
||||
[[permissions]]
|
||||
code = "product.list"
|
||||
@@ -229,6 +428,14 @@ description = "查看商品列表"
|
||||
assert_eq!(events.subscribe.len(), 2);
|
||||
let ui = manifest.ui.unwrap();
|
||||
assert_eq!(ui.pages.len(), 1);
|
||||
// 验证新格式解析正确
|
||||
match &ui.pages[0] {
|
||||
PluginPageType::Crud { entity, label, .. } => {
|
||||
assert_eq!(entity, "product");
|
||||
assert_eq!(label, "商品管理");
|
||||
}
|
||||
_ => panic!("Expected Crud page type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -259,4 +466,137 @@ display_name = "表格"
|
||||
let result = parse_manifest(toml);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_with_new_fields_and_page_types() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test-plugin"
|
||||
name = "Test"
|
||||
version = "0.1.0"
|
||||
description = "Test"
|
||||
author = "Test"
|
||||
min_platform_version = "0.1.0"
|
||||
|
||||
[[schema.entities]]
|
||||
name = "customer"
|
||||
display_name = "客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "code"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "编码"
|
||||
unique = true
|
||||
searchable = true
|
||||
filterable = true
|
||||
visible_when = "customer_type == 'enterprise'"
|
||||
|
||||
[[ui.pages]]
|
||||
type = "tabs"
|
||||
label = "客户管理"
|
||||
icon = "team"
|
||||
|
||||
[[ui.pages.tabs]]
|
||||
label = "客户列表"
|
||||
type = "crud"
|
||||
entity = "customer"
|
||||
enable_search = true
|
||||
enable_views = ["table", "timeline"]
|
||||
|
||||
[[ui.pages]]
|
||||
type = "detail"
|
||||
entity = "customer"
|
||||
label = "客户详情"
|
||||
|
||||
[[ui.pages.sections]]
|
||||
type = "fields"
|
||||
label = "基本信息"
|
||||
fields = ["code", "name"]
|
||||
"#;
|
||||
let manifest = parse_manifest(toml).expect("should parse");
|
||||
let field = &manifest.schema.as_ref().unwrap().entities[0].fields[0];
|
||||
assert_eq!(field.searchable, Some(true));
|
||||
assert_eq!(field.filterable, Some(true));
|
||||
assert_eq!(
|
||||
field.visible_when.as_deref(),
|
||||
Some("customer_type == 'enterprise'")
|
||||
);
|
||||
// 验证页面类型解析
|
||||
let ui = manifest.ui.as_ref().unwrap();
|
||||
assert_eq!(ui.pages.len(), 2);
|
||||
// tabs 页面
|
||||
match &ui.pages[0] {
|
||||
PluginPageType::Tabs { label, tabs, .. } => {
|
||||
assert_eq!(label, "客户管理");
|
||||
assert_eq!(tabs.len(), 1);
|
||||
}
|
||||
_ => panic!("Expected Tabs page type"),
|
||||
}
|
||||
// detail 页面
|
||||
match &ui.pages[1] {
|
||||
PluginPageType::Detail {
|
||||
entity, sections, ..
|
||||
} => {
|
||||
assert_eq!(entity, "customer");
|
||||
assert_eq!(sections.len(), 1);
|
||||
}
|
||||
_ => panic!("Expected Detail page type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_empty_entity_in_crud_page() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "0.1.0"
|
||||
|
||||
[ui]
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
entity = ""
|
||||
label = "测试"
|
||||
"#;
|
||||
let result = parse_manifest(toml);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_empty_tabs_in_tabs_page() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "0.1.0"
|
||||
|
||||
[ui]
|
||||
[[ui.pages]]
|
||||
type = "tabs"
|
||||
label = "空标签页"
|
||||
"#;
|
||||
let result = parse_manifest(toml);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_type_to_sql_mapping() {
|
||||
assert_eq!(PluginFieldType::String.generated_sql_type(), "TEXT");
|
||||
assert_eq!(PluginFieldType::Integer.generated_sql_type(), "INTEGER");
|
||||
assert_eq!(PluginFieldType::Float.generated_sql_type(), "DOUBLE PRECISION");
|
||||
assert_eq!(PluginFieldType::Decimal.generated_sql_type(), "NUMERIC");
|
||||
assert_eq!(PluginFieldType::Boolean.generated_sql_type(), "BOOLEAN");
|
||||
assert_eq!(PluginFieldType::Date.generated_sql_type(), "DATE");
|
||||
assert_eq!(PluginFieldType::DateTime.generated_sql_type(), "TIMESTAMPTZ");
|
||||
assert_eq!(PluginFieldType::Uuid.generated_sql_type(), "UUID");
|
||||
assert_eq!(PluginFieldType::Json.generated_sql_type(), "TEXT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_type_generated_expression() {
|
||||
assert_eq!(PluginFieldType::String.generated_expr("name"), "data->>'name'");
|
||||
assert_eq!(PluginFieldType::Integer.generated_expr("age"), "(data->>'age')::INTEGER");
|
||||
assert_eq!(PluginFieldType::Uuid.generated_expr("ref_id"), "(data->>'ref_id')::UUID");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,15 @@ impl PluginModule {
|
||||
get(crate::handler::data_handler::get_plugin_data::<S>)
|
||||
.put(crate::handler::data_handler::update_plugin_data::<S>)
|
||||
.delete(crate::handler::data_handler::delete_plugin_data::<S>),
|
||||
)
|
||||
// 数据统计路由
|
||||
.route(
|
||||
"/plugins/{plugin_id}/{entity}/count",
|
||||
get(crate::handler::data_handler::count_plugin_data::<S>),
|
||||
)
|
||||
.route(
|
||||
"/plugins/{plugin_id}/{entity}/aggregate",
|
||||
get(crate::handler::data_handler::aggregate_plugin_data::<S>),
|
||||
);
|
||||
|
||||
admin_routes.merge(data_routes)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
@@ -76,7 +76,7 @@ impl PluginService {
|
||||
Ok(plugin_model_to_resp(&model, &manifest, vec![]))
|
||||
}
|
||||
|
||||
/// 安装插件: 创建动态表 + 注册 entity 记录 + 注册事件订阅 + status=installed
|
||||
/// 安装插件: 创建动态表 + 注册 entity 记录 + 注册事件订阅 + 注册权限 + status=installed
|
||||
pub async fn install(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
@@ -96,11 +96,16 @@ impl PluginService {
|
||||
// 创建动态表 + 注册 entity 记录
|
||||
let mut entity_resps = Vec::new();
|
||||
if let Some(schema) = &manifest.schema {
|
||||
for entity_def in &schema.entities {
|
||||
for (i, entity_def) in schema.entities.iter().enumerate() {
|
||||
let table_name = DynamicTableManager::table_name(&manifest.metadata.id, &entity_def.name);
|
||||
tracing::info!(step = i, entity = %entity_def.name, table = %table_name, "Creating dynamic table");
|
||||
|
||||
// 创建动态表
|
||||
DynamicTableManager::create_table(db, &manifest.metadata.id, entity_def).await?;
|
||||
DynamicTableManager::create_table(db, &manifest.metadata.id, entity_def).await
|
||||
.map_err(|e| {
|
||||
tracing::error!(entity = %entity_def.name, table = %table_name, error = %e, "Failed to create dynamic table");
|
||||
e
|
||||
})?;
|
||||
|
||||
// 注册 entity 记录
|
||||
let entity_id = Uuid::now_v7();
|
||||
@@ -142,7 +147,26 @@ impl PluginService {
|
||||
}
|
||||
}
|
||||
|
||||
// 注册插件声明的权限到 permissions 表
|
||||
tracing::info!("Registering plugin permissions");
|
||||
if let Some(perms) = &manifest.permissions {
|
||||
register_plugin_permissions(
|
||||
db,
|
||||
tenant_id,
|
||||
operator_id,
|
||||
&manifest.metadata.id,
|
||||
perms,
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to register permissions");
|
||||
e
|
||||
})?;
|
||||
}
|
||||
|
||||
// 加载到内存
|
||||
tracing::info!(manifest_id = %manifest.metadata.id, "Loading plugin into engine");
|
||||
engine
|
||||
.load(
|
||||
&manifest.metadata.id,
|
||||
@@ -236,7 +260,7 @@ impl PluginService {
|
||||
Ok(plugin_model_to_resp(&model, &manifest, entity_resps))
|
||||
}
|
||||
|
||||
/// 卸载插件: unload + 有条件地 drop 动态表 + status=uninstalled
|
||||
/// 卸载插件: unload + 有条件地 drop 动态表 + 清理权限 + status=uninstalled
|
||||
pub async fn uninstall(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
@@ -294,6 +318,9 @@ impl PluginService {
|
||||
}
|
||||
}
|
||||
|
||||
// 清理此插件注册的权限
|
||||
unregister_plugin_permissions(db, tenant_id, &manifest.metadata.id).await?;
|
||||
|
||||
let mut active: plugin::ActiveModel = model.into();
|
||||
active.status = Set("uninstalled".to_string());
|
||||
active.updated_at = Set(now);
|
||||
@@ -438,7 +465,22 @@ impl PluginService {
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_value(model.manifest_json.clone())
|
||||
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||
Ok(serde_json::to_value(&manifest.schema).unwrap_or_default())
|
||||
|
||||
// 构建 schema 响应:entities + ui 页面配置
|
||||
let mut result = serde_json::Map::new();
|
||||
if let Some(schema) = &manifest.schema {
|
||||
result.insert(
|
||||
"entities".to_string(),
|
||||
serde_json::to_value(&schema.entities).unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
if let Some(ui) = &manifest.ui {
|
||||
result.insert(
|
||||
"ui".to_string(),
|
||||
serde_json::to_value(ui).unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
Ok(serde_json::Value::Object(result))
|
||||
}
|
||||
|
||||
/// 清除插件记录(软删除,仅限已卸载状态)
|
||||
@@ -449,7 +491,7 @@ impl PluginService {
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<()> {
|
||||
let model = find_plugin(plugin_id, tenant_id, db).await?;
|
||||
validate_status(&model.status, "uninstalled")?;
|
||||
validate_status_any(&model.status, &["uninstalled", "uploaded"])?;
|
||||
let now = Utc::now();
|
||||
let mut active: plugin::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(now));
|
||||
@@ -553,3 +595,145 @@ fn plugin_model_to_resp(
|
||||
record_version: model.version,
|
||||
}
|
||||
}
|
||||
|
||||
/// 将插件声明的权限注册到 permissions 表。
|
||||
///
|
||||
/// 使用 raw SQL 避免依赖 erp-auth 的 entity 类型。
|
||||
/// 权限码格式:`{plugin_manifest_id}.{code}`(如 `erp-crm.customer.list`)。
|
||||
/// 使用 `ON CONFLICT DO NOTHING` 保证幂等。
|
||||
async fn register_plugin_permissions(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
plugin_manifest_id: &str,
|
||||
perms: &[crate::manifest::PluginPermission],
|
||||
now: &chrono::DateTime<chrono::Utc>,
|
||||
) -> AppResult<()> {
|
||||
for perm in perms {
|
||||
let full_code = format!("{}.{}", plugin_manifest_id, perm.code);
|
||||
let resource = plugin_manifest_id.to_string();
|
||||
let action = perm.code.clone();
|
||||
let description: Option<String> = if perm.description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(perm.description.clone())
|
||||
};
|
||||
|
||||
let sql = r#"
|
||||
INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $7, $8, $8, NULL, 1)
|
||||
ON CONFLICT (tenant_id, code) WHERE deleted_at IS NULL DO NOTHING
|
||||
"#;
|
||||
|
||||
db.execute(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
vec![
|
||||
sea_orm::Value::from(tenant_id),
|
||||
sea_orm::Value::from(full_code.clone()),
|
||||
sea_orm::Value::from(perm.name.clone()),
|
||||
sea_orm::Value::from(resource),
|
||||
sea_orm::Value::from(action),
|
||||
sea_orm::Value::from(description),
|
||||
sea_orm::Value::from(now.clone()),
|
||||
sea_orm::Value::from(operator_id),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
plugin = plugin_manifest_id,
|
||||
permission = %full_code,
|
||||
error = %e,
|
||||
"注册插件权限失败"
|
||||
);
|
||||
PluginError::DatabaseError(format!("注册插件权限 {} 失败: {}", full_code, e))
|
||||
})?;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
plugin = plugin_manifest_id,
|
||||
count = perms.len(),
|
||||
tenant_id = %tenant_id,
|
||||
"插件权限注册完成"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清理插件注册的权限(软删除)。
|
||||
///
|
||||
/// 使用 raw SQL 按前缀匹配清理:`{plugin_manifest_id}.%`。
|
||||
/// 同时清理 role_permissions 中对这些权限的关联。
|
||||
async fn unregister_plugin_permissions(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
plugin_manifest_id: &str,
|
||||
) -> AppResult<()> {
|
||||
let prefix = format!("{}.%", plugin_manifest_id);
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
// 先软删除 role_permissions 中的关联
|
||||
let rp_sql = r#"
|
||||
UPDATE role_permissions
|
||||
SET deleted_at = $1, updated_at = $1
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions
|
||||
WHERE tenant_id = $2
|
||||
AND code LIKE $3
|
||||
AND deleted_at IS NULL
|
||||
)
|
||||
AND deleted_at IS NULL
|
||||
"#;
|
||||
db.execute(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
rp_sql,
|
||||
vec![
|
||||
sea_orm::Value::from(now.clone()),
|
||||
sea_orm::Value::from(tenant_id),
|
||||
sea_orm::Value::from(prefix.clone()),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
plugin = plugin_manifest_id,
|
||||
error = %e,
|
||||
"清理插件权限角色关联失败"
|
||||
);
|
||||
PluginError::DatabaseError(format!("清理插件权限角色关联失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 再软删除 permissions
|
||||
let perm_sql = r#"
|
||||
UPDATE permissions
|
||||
SET deleted_at = $1, updated_at = $1
|
||||
WHERE tenant_id = $2
|
||||
AND code LIKE $3
|
||||
AND deleted_at IS NULL
|
||||
"#;
|
||||
db.execute(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
perm_sql,
|
||||
vec![
|
||||
sea_orm::Value::from(now),
|
||||
sea_orm::Value::from(tenant_id),
|
||||
sea_orm::Value::from(prefix),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
plugin = plugin_manifest_id,
|
||||
error = %e,
|
||||
"清理插件权限失败"
|
||||
);
|
||||
PluginError::DatabaseError(format!("清理插件权限失败: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
plugin = plugin_manifest_id,
|
||||
tenant_id = %tenant_id,
|
||||
"插件权限清理完成"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ mod m20260415_000030_add_version_to_message_tables;
|
||||
mod m20260416_000031_create_domain_events;
|
||||
mod m20260417_000033_create_plugins;
|
||||
mod m20260417_000034_seed_plugin_permissions;
|
||||
mod m20260418_000035_pg_trgm_and_entity_columns;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -75,6 +76,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260414_000032_fix_settings_unique_index_null::Migration),
|
||||
Box::new(m20260417_000033_create_plugins::Migration),
|
||||
Box::new(m20260417_000034_seed_plugin_permissions::Migration),
|
||||
Box::new(m20260418_000035_pg_trgm_and_entity_columns::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 启用 pg_trgm 扩展(加速 ILIKE '%keyword%' 搜索)
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||
.await?;
|
||||
|
||||
// 插件实体列元数据表 — 记录哪些字段被提取为 Generated Column
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("plugin_entity_columns"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.default(Expr::cust("gen_random_uuid()"))
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("plugin_entity_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("field_name")).string().not_null())
|
||||
.col(ColumnDef::new(Alias::new("column_name")).string().not_null())
|
||||
.col(ColumnDef::new(Alias::new("sql_type")).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("is_generated"))
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(true),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::cust("NOW()")),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// plugin_entity_id 外键
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_plugin_entity_columns_entity")
|
||||
.from(
|
||||
Alias::new("plugin_entity_columns"),
|
||||
Alias::new("plugin_entity_id"),
|
||||
)
|
||||
.to(Alias::new("plugin_entities"), Alias::new("id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(
|
||||
Table::drop()
|
||||
.table(Alias::new("plugin_entity_columns"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
// pg_trgm 不卸载(其他功能可能依赖)
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::http::{Request, StatusCode};
|
||||
@@ -5,6 +8,7 @@ use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use redis::AsyncCommands;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -26,6 +30,53 @@ pub struct RateLimitConfig {
|
||||
pub key_prefix: String,
|
||||
}
|
||||
|
||||
/// Redis 可用性状态缓存,避免重复连接失败时阻塞。
|
||||
struct RedisAvailability {
|
||||
available: AtomicBool,
|
||||
last_check: Mutex<Instant>,
|
||||
}
|
||||
|
||||
impl RedisAvailability {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
available: AtomicBool::new(true),
|
||||
last_check: Mutex::new(Instant::now() - std::time::Duration::from_secs(60)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否应该尝试连接 Redis。
|
||||
/// 如果上次连接失败且冷却期未过,返回 false。
|
||||
async fn should_try(&self) -> bool {
|
||||
if self.available.load(Ordering::Relaxed) {
|
||||
return true;
|
||||
}
|
||||
let mut last = self.last_check.lock().await;
|
||||
// 连接失败后冷却 30 秒再重试
|
||||
if last.elapsed() > std::time::Duration::from_secs(30) {
|
||||
*last = Instant::now();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_ok(&self) {
|
||||
self.available.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
async fn mark_failed(&self) {
|
||||
self.available.store(false, Ordering::Relaxed);
|
||||
*self.last_check.lock().await = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局 Redis 可用性缓存
|
||||
static REDIS_AVAIL: std::sync::OnceLock<RedisAvailability> = std::sync::OnceLock::new();
|
||||
|
||||
fn redis_avail() -> &'static RedisAvailability {
|
||||
REDIS_AVAIL.get_or_init(RedisAvailability::new)
|
||||
}
|
||||
|
||||
/// 基于 Redis 的 IP 限流中间件。
|
||||
///
|
||||
/// 使用 INCR + EXPIRE 实现固定窗口计数器。
|
||||
@@ -65,12 +116,23 @@ async fn apply_rate_limit(
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let avail = redis_avail();
|
||||
|
||||
// 快速跳过:Redis 不可达时直接放行
|
||||
if !avail.should_try().await {
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
let key = format!("rate_limit:{}:{}", prefix, identifier);
|
||||
|
||||
let mut conn = match redis_client.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Ok(c) => {
|
||||
avail.mark_ok();
|
||||
c
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Redis 连接失败,跳过限流");
|
||||
avail.mark_failed().await;
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
3796
docs/superpowers/plans/2026-04-17-crm-plugin-base-upgrade-plan.md
Normal file
3796
docs/superpowers/plans/2026-04-17-crm-plugin-base-upgrade-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,767 @@
|
||||
# CRM 插件基座升级设计规格 v1.0
|
||||
|
||||
> **文档状态:** v1.1 — 已修复评审问题
|
||||
> **创建日期:** 2026-04-17
|
||||
> **范围:** JSONB 存储优化 + 数据完整性框架 + 行级数据权限 + 前端页面能力增强
|
||||
> **评审记录:** code-reviewer 子代理评审通过一轮修复(3 Critical + 7 Important)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
CRM 插件是 ERP 平台的第一个 WASM 行业插件,已完成 3 阶段 24 任务,包含 5 实体、9 权限、7 页面类型。经 6 个专家组深度评审,发现以下结构性问题需要优先解决:
|
||||
|
||||
| 问题 | 严重级别 | 影响 |
|
||||
|------|---------|------|
|
||||
| JSONB 动态表类型安全缺失、排序全表扫描 | High | 万级数据以上性能崩溃 |
|
||||
| JSONB 零外键完整性、零级联策略 | High | 数据"脏"掉,引用断裂 |
|
||||
| 行级数据权限缺失 | Critical | 销售A能看到销售B的所有客户 |
|
||||
| plugin.admin 权限 fallback 过宽 | Critical | 超级用户权限泄露 |
|
||||
| 无关联选择器 (entity_select) | High | UX 极差,客户ID手动输入 |
|
||||
| 无看板/批量操作/图表等页面能力 | Medium | CRM 功能不完整 |
|
||||
|
||||
**核心原则:** 基座优先。所有改进沉淀为插件平台通用能力,CRM 作为第一受益者而非唯一受益者。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计目标
|
||||
|
||||
1. **JSONB 存储优化** — 百万级数据下列表查询 p95 < 200ms,搜索 p95 < 300ms
|
||||
2. **数据完整性框架** — 应用层外键校验、级联策略、字段校验、循环引用检测
|
||||
3. **行级数据权限** — 支持 self/department/department_tree/all 四级数据范围
|
||||
4. **前端页面能力增强** — 关联选择器、看板页面、批量操作、Dashboard 图表、visible_when 增强
|
||||
|
||||
---
|
||||
|
||||
## 3. JSONB 存储优化
|
||||
|
||||
### 3.1 Generated Column 混合存储
|
||||
|
||||
利用 PostgreSQL 12+ 的 `GENERATED ALWAYS AS ... STORED` 列,自动从 JSONB `data` 列提取高频字段到独立列。数据只存一份(在 JSONB 中),Generated Column 是自动派生的,零维护成本。
|
||||
|
||||
**提取规则(在 `dynamic_table.rs` 的 `create_table` 中自动判断):**
|
||||
|
||||
| 字段特征 | 提取策略 | 原因 |
|
||||
|----------|---------|------|
|
||||
| `unique == true` | Generated Column + UNIQUE INDEX | 需要精确唯一性约束 |
|
||||
| `required == true && (sortable \|\| filterable)` | Generated Column + INDEX | 需要类型化排序/筛选 |
|
||||
| `sortable == true` | Generated Column + INDEX | ORDER BY 走 B-tree |
|
||||
| `filterable == true` | Generated Column + INDEX | WHERE 走索引扫描 |
|
||||
| `searchable == true` | 保留 JSONB + pg_trgm GIN 索引 | 模糊搜索用三元组索引 |
|
||||
| 其他字段 | 保留 JSONB | 无需索引 |
|
||||
|
||||
**生成的 DDL 示例:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE plugin_erp_crm_customer (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
data JSONB NOT NULL DEFAULT '{}',
|
||||
-- Generated Columns
|
||||
_f_code TEXT GENERATED ALWAYS AS (data->>'code') STORED,
|
||||
_f_name TEXT GENERATED ALWAYS AS (data->>'name') STORED,
|
||||
_f_customer_type TEXT GENERATED ALWAYS AS (data->>'customer_type') STORED,
|
||||
_f_status TEXT GENERATED ALWAYS AS (data->>'status') STORED,
|
||||
_f_level TEXT GENERATED ALWAYS AS (data->>'level') STORED,
|
||||
-- 标准字段
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID,
|
||||
updated_by UUID,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
version INT NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- 复合索引(tenant_id 在前,支持多租户过滤)
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_tenant_cover
|
||||
ON "{t}" (tenant_id, created_at DESC)
|
||||
INCLUDE (id, data, updated_at, version)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_f_name_sort
|
||||
ON "{t}" (tenant_id, _f_name)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_{t}_f_code_uniq
|
||||
ON "{t}" (tenant_id, _f_code)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_f_type_filter
|
||||
ON "{t}" (tenant_id, _f_customer_type)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**SQL 查询路由:** 在 `dynamic_table.rs` 中新增 `GeneratedColumnInfo` 结构,记录哪些字段被提取为 Generated Column。`build_filtered_query_sql` 和 `build_aggregate_sql` 检测到对应 Generated Column 存在时,自动将 `data->>'field'` 替换为 `_f_{field}`。
|
||||
|
||||
**类型映射:** `data->>'field'` 始终返回 TEXT。对于非字符串类型,Generated Column 需要类型转换以支持正确的排序和比较:
|
||||
|
||||
| field_type | SQL 类型 | Generated Column 表达式 |
|
||||
|------------|---------|------------------------|
|
||||
| String | TEXT | `data->>'field'` |
|
||||
| Integer | INTEGER | `(data->>'field')::INTEGER` |
|
||||
| Float | DOUBLE PRECISION | `(data->>'field')::DOUBLE PRECISION` |
|
||||
| Decimal | NUMERIC(18,4) | `(data->>'field')::NUMERIC` |
|
||||
| Boolean | BOOLEAN | `(data->>'field')::BOOLEAN` |
|
||||
| Date | DATE | `(data->>'field')::DATE` |
|
||||
| DateTime | TIMESTAMPTZ | `(data->>'field')::TIMESTAMPTZ` |
|
||||
| Uuid | UUID | `(data->>'field')::UUID` |
|
||||
|
||||
`dynamic_table.rs` 的 `create_table` 根据 `PluginField.field_type` 自动选择正确的 SQL 类型和类型转换表达式。
|
||||
|
||||
**元数据表:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS plugin_entity_columns (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL, -- 多租户标准字段
|
||||
plugin_entity_id UUID NOT NULL REFERENCES plugin_entities(id),
|
||||
field_name VARCHAR(100) NOT NULL,
|
||||
column_name VARCHAR(100) NOT NULL, -- 如 _f_name
|
||||
sql_type VARCHAR(50) NOT NULL, -- 如 TEXT, INTEGER, UUID
|
||||
is_generated BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Schema 演变策略(重新安装/字段变更):**
|
||||
|
||||
当前 `service.rs` 的 `install` 使用 `CREATE TABLE IF NOT EXISTS`。引入 Generated Column 后,安装流程改为:
|
||||
|
||||
1. **首次安装**:`CREATE TABLE` 包含所有 Generated Column。
|
||||
2. **重新安装(同版本)**:`IF NOT EXISTS` 跳过表创建。比对 `plugin_entity_columns` 元数据与当前 manifest 的字段列表,执行增量 ALTER:
|
||||
- 新增字段:`ALTER TABLE ADD COLUMN _f_{name} {type} GENERATED ALWAYS AS (...) STORED`
|
||||
- 删除字段:`ALTER TABLE DROP COLUMN _f_{name}`(仅删除 Generated Column,JSONB data 中的原始值保留)
|
||||
- 类型变更:PostgreSQL 不支持 ALTER GENERATED COLUMN 的表达式,需 DROP + ADD
|
||||
3. **插件卸载时**:表被删除,元数据自动清理。
|
||||
|
||||
`dynamic_table.rs` 新增 `migrate_table` 方法,接受已有列列表和目标列列表,生成增量 DDL。
|
||||
|
||||
### 3.2 pg_trgm 模糊搜索加速
|
||||
|
||||
**迁移文件:** 在 `erp-server/migration` 中新增迁移启用 pg_trgm 扩展:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
```
|
||||
|
||||
**索引创建:** `create_table` 中 searchable 字段的索引从普通 B-tree 改为 GIN 三元组:
|
||||
|
||||
```sql
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_{f}_trgm
|
||||
ON "{t}" USING GIN ((data->>'{f}') gin_trgm_ops)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
启用后 `ILIKE '%keyword%'` 从全表扫描退化为索引扫描,百万级数据搜索从 2-5s 降至 50-200ms。
|
||||
|
||||
### 3.3 Keyset Pagination
|
||||
|
||||
**向后兼容设计:** API 同时支持 OFFSET 和 cursor 两种分页模式。
|
||||
|
||||
`data_dto.rs` 中 `PluginDataListParams` 新增 `cursor` 字段:
|
||||
|
||||
```rust
|
||||
pub struct PluginDataListParams {
|
||||
pub page: Option<u64>, // 保留,向后兼容
|
||||
pub page_size: Option<u64>,
|
||||
pub cursor: Option<String>, // 新增:Base64 编码的游标
|
||||
pub search: Option<String>,
|
||||
pub filter: Option<String>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
`dynamic_table.rs` 中 SQL 构建逻辑:当 `cursor` 存在时使用 keyset 分页:
|
||||
|
||||
**游标编码格式:** JSON 结构 `{ "v": [value1, value2, ...], "id": "uuid" }`,Base64 编码。`v` 数组存储排序字段的值(与 sort_by 顺序一致),`id` 是记录主键作为最终 tiebreaker。多列排序时 `v` 包含多个值。字段值为 null 时存储 JSON null。
|
||||
|
||||
客户端必须在每次请求中同时发送 `cursor` 和 `sort_by`/`sort_order`(游标不嵌入排序信息,保持无状态)。
|
||||
|
||||
```sql
|
||||
-- 第一页
|
||||
SELECT ... ORDER BY _f_name ASC, id ASC LIMIT 20;
|
||||
|
||||
-- 后续页(cursor 解码后)
|
||||
SELECT ... WHERE (_f_name, id) > ($cursor_sort_val, $cursor_id)
|
||||
ORDER BY _f_name ASC, id ASC LIMIT 20;
|
||||
```
|
||||
|
||||
### 3.4 Schema 缓存
|
||||
|
||||
在 `PluginState` 中添加 `moka` LRU 缓存,消除每次数据请求的 `resolve_entity_info` 查库:
|
||||
|
||||
```rust
|
||||
pub entity_cache: Cache<String, EntityInfo>, // key: "{plugin_id}:{entity_name}:{tenant_id}"
|
||||
```
|
||||
|
||||
TTL 5 分钟,容量 1000 条。
|
||||
|
||||
### 3.5 聚合 Redis 缓存
|
||||
|
||||
`data_service.rs` 的 create/update/delete 成功后增量更新 Redis 统计:
|
||||
|
||||
```
|
||||
plugin:{plugin_id}:{entity}:count:{tenant_id} → 计数值
|
||||
plugin:{plugin_id}:{entity}:agg:{field}:{tenant_id} → JSON {key: count}
|
||||
```
|
||||
|
||||
Dashboard 查询直接从 Redis 读取,TTL 5 分钟兜底。
|
||||
|
||||
### 3.6 性能 SLA 目标
|
||||
|
||||
**测试条件:** PostgreSQL 与应用同机部署(Redis localhost 延迟 < 1ms)。SLA 包含 Redis 往返(schema 缓存 + 部门缓存)。冷启动(Redis 缓存未命中)首次查询允许 3x SLA 宽限。
|
||||
|
||||
| 查询场景 | 数据量 | p50 | p95 | p99 |
|
||||
|----------|--------|-----|-----|-----|
|
||||
| 按 ID 获取单条 | 100万 | < 5ms | < 10ms | < 20ms |
|
||||
| 列表查询(默认排序) | 100万 | < 20ms | < 50ms | < 100ms |
|
||||
| 列表查询(字段排序) | 100万 | < 30ms | < 100ms | < 200ms |
|
||||
| 搜索(ILIKE) | 100万 | < 50ms | < 100ms | < 300ms |
|
||||
| 聚合查询 | 100万 | < 50ms (缓存) | < 500ms (实时) | - |
|
||||
| Dashboard 全量加载 | 100万 | < 200ms | < 500ms | - |
|
||||
|
||||
### 3.7 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | 主要改动 — Generated Column DDL、索引策略、SQL 路由、keyset 分页 |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 缓存逻辑、聚合 Redis 缓存 |
|
||||
| `crates/erp-plugin/src/data_dto.rs` | 新增 cursor 参数 |
|
||||
| `crates/erp-plugin/src/state.rs` | 新增 entity_cache |
|
||||
| `crates/erp-plugin/src/manifest.rs` | PluginEntityColumns 元数据 |
|
||||
| `crates/erp-server/migration/src/` | pg_trgm 扩展 + plugin_entity_columns 表 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据完整性框架
|
||||
|
||||
### 4.1 外键引用声明
|
||||
|
||||
`manifest.rs` 的 `PluginField` 新增 `ref_entity` 字段:
|
||||
|
||||
```rust
|
||||
pub struct PluginField {
|
||||
pub name: String,
|
||||
pub field_type: PluginFieldType,
|
||||
// ...已有字段...
|
||||
pub ref_entity: Option<String>, // 新增:引用的实体名
|
||||
}
|
||||
```
|
||||
|
||||
manifest TOML 中的使用方式:
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "所属客户"
|
||||
ref_entity = "customer" # 声明外键引用
|
||||
```
|
||||
|
||||
### 4.2 应用层外键校验
|
||||
|
||||
在 `data_service.rs` 的 `validate_data` 函数中扩展:
|
||||
|
||||
```
|
||||
create/update 时:
|
||||
遍历 fields,如果 field.ref_entity 存在:
|
||||
1. 从 data 中取出该字段的 UUID 值
|
||||
2. 如果值为 null 或空字符串且 required == false → 跳过校验
|
||||
3. 如果是自引用(ref_entity == 当前实体名)且为 create 操作:
|
||||
a. 如果引用的是自身 ID → 跳过(记录尚不存在,无法校验)
|
||||
b. 如果引用的是其他记录 → 正常校验
|
||||
4. 查询 ref_entity 对应的动态表,验证该记录存在且未删除
|
||||
5. 不存在则返回 ValidationError
|
||||
|
||||
TOCTOU 竞态说明:
|
||||
外键校验与引用记录删除之间存在理论上的竞态窗口。
|
||||
对于 JSONB 动态表,这是可接受的风险——应用层校验已大幅降低孤立引用概率。
|
||||
如果未来需要严格保证,可在 flush_ops 中增加二次校验(事务内 SELECT FOR UPDATE)。
|
||||
```
|
||||
|
||||
### 4.3 级联删除策略
|
||||
|
||||
`manifest.rs` 新增 `PluginRelation` 结构:
|
||||
|
||||
```rust
|
||||
pub struct PluginRelation {
|
||||
pub entity: String, // 关联实体名
|
||||
pub foreign_key: String, // 关联实体中的外键字段名
|
||||
pub on_delete: OnDeleteStrategy, // 级联策略
|
||||
}
|
||||
|
||||
pub enum OnDeleteStrategy {
|
||||
Nullify, // 置空外键字段
|
||||
Cascade, // 级联软删除
|
||||
Restrict, // 存在关联时拒绝删除
|
||||
}
|
||||
```
|
||||
|
||||
manifest TOML 中的使用方式:
|
||||
|
||||
```toml
|
||||
[[schema.entities.relations]]
|
||||
entity = "contact"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "nullify"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "communication"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer_tag"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
```
|
||||
|
||||
`data_service.rs` 的 `delete` 方法中,在软删除记录之前:
|
||||
|
||||
```
|
||||
1. 从 manifest 中查找该实体声明的所有 relations
|
||||
2. 对每个 relation:
|
||||
- Restrict: 查询关联实体是否有引用 → 有则拒绝删除
|
||||
- Nullify: 批量 UPDATE 关联记录,将 foreign_key 设为 null
|
||||
- Cascade: 批量软删除关联记录(级联深度上限 3 层,防止 A→B→C→D 无限递归)
|
||||
```
|
||||
|
||||
### 4.4 字段校验规则
|
||||
|
||||
`manifest.rs` 的 `PluginField` 新增 `validation` 子结构:
|
||||
|
||||
```rust
|
||||
pub struct FieldValidation {
|
||||
pub pattern: Option<String>, // 正则表达式
|
||||
pub message: Option<String>, // 校验失败提示
|
||||
}
|
||||
```
|
||||
|
||||
manifest TOML 中的使用方式:
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "phone"
|
||||
field_type = "string"
|
||||
display_name = "手机号"
|
||||
validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" }
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "email"
|
||||
field_type = "string"
|
||||
display_name = "邮箱"
|
||||
validation = { pattern = "^[\\w.-]+@[\\w.-]+\\.\\w+$", message = "邮箱格式不正确" }
|
||||
```
|
||||
|
||||
`validate_data` 扩展:对有 `validation.pattern` 的字段,使用 `regex` crate 做正则匹配。
|
||||
|
||||
### 4.5 循环引用检测
|
||||
|
||||
`manifest.rs` 的 `PluginField` 新增 `no_cycle` 字段:
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "parent_id"
|
||||
field_type = "uuid"
|
||||
ref_entity = "customer"
|
||||
no_cycle = true # 声明不允许循环引用
|
||||
```
|
||||
|
||||
`data_service.rs` 的 `update` 方法中,当 `no_cycle == true` 的字段被修改时:
|
||||
|
||||
```
|
||||
1. 从 data 中取出新值 (new_parent_id)
|
||||
2. 初始化 visited = {record_id}
|
||||
3. 循环:查询 current 的 parent_id → 如果在 visited 中则报错 → 加入 visited
|
||||
4. 直到 parent_id 为 null 或到达根节点
|
||||
```
|
||||
|
||||
### 4.6 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-plugin/src/manifest.rs` | 新增 ref_entity / PluginRelation / FieldValidation / no_cycle |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 外键校验 / 级联删除 / 字段校验 / 循环检测 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | 为现有字段添加 ref_entity / relations / validation / no_cycle 声明 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 行级数据权限
|
||||
|
||||
### 5.1 数据范围模型
|
||||
|
||||
在实体级别声明是否启用行级数据权限,在权限级别声明数据范围等级。
|
||||
|
||||
**manifest 扩展:**
|
||||
|
||||
```toml
|
||||
[[schema.entities]]
|
||||
name = "customer"
|
||||
display_name = "客户"
|
||||
data_scope = true # 启用行级数据权限
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "owner_id"
|
||||
field_type = "uuid"
|
||||
display_name = "负责人"
|
||||
scope_role = "owner" # 标记为数据权限的"所有者"字段
|
||||
```
|
||||
|
||||
**权限声明扩展:**
|
||||
|
||||
```toml
|
||||
[[permissions]]
|
||||
code = "customer.list"
|
||||
name = "查看客户"
|
||||
data_scope_levels = ["self", "department", "department_tree", "all"]
|
||||
```
|
||||
|
||||
### 5.2 数据范围等级定义
|
||||
|
||||
| 等级 | 含义 | SQL 条件 |
|
||||
|------|------|---------|
|
||||
| `self` | 只看自己负责/创建的 | `data->>'owner_id' = current_user_id OR created_by = current_user_id` |
|
||||
| `department` | 看本部门所有人的 | `data->>'owner_id' IN (部门用户列表)` |
|
||||
| `department_tree` | 看本部门及下级部门 | `data->>'owner_id' IN (部门树用户列表)` |
|
||||
| `all` | 看全部 | 无额外条件 |
|
||||
|
||||
### 5.3 实现路径
|
||||
|
||||
**TenantContext 扩展:** `erp-core` 的 `TenantContext` 结构新增 `department_ids: Vec<Uuid>` 字段(注意:用户可通过岗位属于多个部门)。JWT claims 中新增 `dept_ids` 字段,JWT 中间件在构造 TenantContext 时填充。
|
||||
|
||||
**多部门用户处理:** 用户通过 Position 关联到多个 Department。`department` 级别取所有所属部门的并集;`department_tree` 取所有所属部门及其下级部门的并集。没有岗位/部门的用户在 `department` 和 `department_tree` 级别下只能看到自己创建的数据(降级为 self)。
|
||||
|
||||
**角色权限表扩展:** `role_permissions` 表新增 `data_scope` 字段(VARCHAR(32),默认值 `'all'`)。新增迁移文件 `m20260418_*_add_data_scope_to_role_permissions.rs`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE role_permissions ADD COLUMN IF NOT EXISTS data_scope VARCHAR(32) NOT NULL DEFAULT 'all';
|
||||
```
|
||||
|
||||
**管理界面适配:** 角色权限分配界面新增"数据范围"下拉选项,管理员为每个权限分配时选择 self/department/department_tree/all。
|
||||
|
||||
**查询注入:** `data_service.rs` 的 `list` / `count` / `aggregate` 方法中:
|
||||
|
||||
```
|
||||
1. 从权限检查结果中获取该权限对应的 data_scope 等级
|
||||
2. 如果实体启用了 data_scope:
|
||||
- self: 注入 owner_id / created_by 过滤条件
|
||||
- department: 查询用户所在部门的所有用户 ID,注入 IN 条件
|
||||
- department_tree: 递归查询部门树,注入 IN 条件
|
||||
- all: 无额外条件
|
||||
3. 将条件追加到 dynamic_table 的 SQL 构建中
|
||||
```
|
||||
|
||||
**部门用户缓存:** 使用 Redis 缓存部门-用户映射关系,TTL 10 分钟,避免每次查询都递归查部门树。当部门分配变更时通过 EventBus 事件 (`department.member_changed`) 失效缓存。
|
||||
|
||||
### 5.4 权限 fallback 收紧
|
||||
|
||||
**当前行为(危险):** `data_handler.rs` 中,如果没有实体级权限,fallback 到 `plugin.admin`,获得所有数据访问权。
|
||||
|
||||
**修改后:** 移除 fallback 逻辑。权限检查链改为:
|
||||
|
||||
```
|
||||
1. 检查实体级权限 ({manifest_id}.{entity}.{action})
|
||||
2. 存在 → 通过,附带 data_scope
|
||||
3. 不存在 → 拒绝 (403)
|
||||
```
|
||||
|
||||
`plugin.admin` 只管理插件生命周期(上传/安装/启用/禁用/卸载),不自动获得数据访问权。需要显式分配实体级权限。
|
||||
|
||||
**迁移策略(避免现有管理员失去访问):** 在收紧 fallback 的迁移中,同时执行以下补偿:
|
||||
|
||||
```sql
|
||||
-- 为所有拥有 plugin.admin 权限的角色,自动分配所有已安装插件的实体级权限
|
||||
-- data_scope 默认设为 'all'(管理员级别)
|
||||
INSERT INTO role_permissions (id, role_id, permission_id, tenant_id, data_scope, ...)
|
||||
SELECT gen_random_uuid(), rp.role_id, p.id, rp.tenant_id, 'all', ...
|
||||
FROM role_permissions rp
|
||||
JOIN permissions p ON p.tenant_id = rp.tenant_id
|
||||
WHERE rp.permission_id = (SELECT id FROM permissions WHERE code = 'plugin.admin')
|
||||
AND p.code LIKE 'erp-%' -- 所有插件实体权限
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp2
|
||||
WHERE rp2.role_id = rp.role_id AND rp2.permission_id = p.id
|
||||
);
|
||||
```
|
||||
|
||||
这确保现有管理员在 fallback 收紧后仍保持完整的数据访问能力。
|
||||
|
||||
### 5.5 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-core/src/types.rs` | TenantContext 新增 department_ids 字段 |
|
||||
| `crates/erp-auth/src/middleware/jwt_auth.rs` | JWT claims 解析 department_ids |
|
||||
| `crates/erp-plugin/src/manifest.rs` | data_scope / scope_role / data_scope_levels |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 查询条件注入 |
|
||||
| `crates/erp-plugin/src/handler/data_handler.rs` | 移除权限 fallback |
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | SQL 构建支持数据范围条件 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | customer 实体添加 data_scope / owner_id |
|
||||
| `crates/erp-server/migration/src/` | 新增 data_scope 列 + 权限补偿迁移 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端页面能力增强
|
||||
|
||||
### 6.1 关联选择器 (entity_select)
|
||||
|
||||
**Schema 扩展:** `PluginFieldSchema` 新增字段:
|
||||
|
||||
```typescript
|
||||
interface PluginFieldSchema {
|
||||
// ...已有字段...
|
||||
ref_entity?: string; // 引用的实体名
|
||||
ref_label_field?: string; // 显示字段
|
||||
ref_search_fields?: string[]; // 搜索字段
|
||||
cascade_from?: string; // 级联过滤来源字段
|
||||
cascade_filter?: string; // 级联过滤目标字段
|
||||
}
|
||||
```
|
||||
|
||||
**新增组件:** `EntitySelect.tsx` — 通用远程搜索选择器
|
||||
|
||||
```
|
||||
Props: pluginId, entity, labelField, searchFields, cascadeFrom?, cascadeFilter?, value?, onChange?
|
||||
内部: listPluginData(pluginId, entity, {search, filter}) → Ant Design Select + showSearch
|
||||
```
|
||||
|
||||
**manifest TOML:**
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
display_name = "所属客户"
|
||||
ui_widget = "entity_select"
|
||||
ref_entity = "customer"
|
||||
ref_label_field = "name"
|
||||
ref_search_fields = ["name", "code"]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "contact_id"
|
||||
field_type = "uuid"
|
||||
display_name = "关联联系人"
|
||||
ui_widget = "entity_select"
|
||||
ref_entity = "contact"
|
||||
ref_label_field = "name"
|
||||
ref_search_fields = ["name"]
|
||||
cascade_from = "customer_id" # 选了客户后自动过滤
|
||||
cascade_filter = "customer_id"
|
||||
```
|
||||
|
||||
### 6.2 Kanban 看板页面
|
||||
|
||||
**Schema 扩展:** `PluginPageType` 新增 `Kanban` 变体。
|
||||
|
||||
**manifest TOML:**
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "kanban"
|
||||
entity = "customer"
|
||||
label = "销售漏斗"
|
||||
icon = "swap"
|
||||
lane_field = "level"
|
||||
lane_order = ["potential", "normal", "vip", "svip"]
|
||||
card_title_field = "name"
|
||||
card_subtitle_field = "code"
|
||||
card_fields = ["name", "code", "region", "status"]
|
||||
enable_drag = true
|
||||
```
|
||||
|
||||
**新增组件:** `PluginKanbanPage.tsx`
|
||||
|
||||
- 使用 `@dnd-kit/core` + `@dnd-kit/sortable` 实现跨列拖拽
|
||||
- 每列使用 Ant Design Card 渲染卡片
|
||||
- 每列内支持虚拟滚动(节点数 > 50 时)
|
||||
- 拖拽结束调用 `PATCH /plugins/{id}/{entity}/{recordId}` 更新 lane_field 值
|
||||
|
||||
**后端新增:** `PATCH` 部分更新端点(当前只有 PUT 全量更新):
|
||||
|
||||
```
|
||||
PATCH /api/v1/plugins/{plugin_id}/{entity}/{id}
|
||||
Body: { "data": { "level": "vip" }, "version": 3 }
|
||||
```
|
||||
|
||||
与 PUT 的区别:PATCH 只更新 data 中提供的字段,未提供的字段保持不变。
|
||||
|
||||
### 6.3 批量操作
|
||||
|
||||
**CRUD 页面增强:** `PluginCRUDPage.tsx` 新增 `rowSelection` 和批量操作栏。
|
||||
|
||||
**manifest TOML:**
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
entity = "customer"
|
||||
enable_batch = true
|
||||
|
||||
[[ui.pages.batch_actions]]
|
||||
label = "批量删除"
|
||||
action = "batch_delete"
|
||||
permission = "customer.manage"
|
||||
confirm = true
|
||||
|
||||
[[ui.pages.batch_actions]]
|
||||
label = "批量修改状态"
|
||||
action = "batch_update"
|
||||
update_field = "status"
|
||||
permission = "customer.manage"
|
||||
```
|
||||
|
||||
**后端新增:** `POST /api/v1/plugins/{id}/{entity}/batch`
|
||||
|
||||
```rust
|
||||
pub enum BatchAction {
|
||||
BatchDelete { ids: Vec<Uuid> },
|
||||
BatchUpdate { ids: Vec<Uuid>, data: serde_json::Value },
|
||||
}
|
||||
```
|
||||
|
||||
批量操作在单个事务中执行,有上限(默认 100 条)。
|
||||
|
||||
### 6.4 visible_when 表达式增强
|
||||
|
||||
**当前:** 只支持 `field == 'value'` 单一等式。
|
||||
|
||||
**增强后支持:**
|
||||
|
||||
```toml
|
||||
visible_when = "customer_type == 'enterprise'"
|
||||
visible_when = "customer_type == 'enterprise' AND level == 'vip'"
|
||||
visible_when = "status == 'active' OR status == 'pending'"
|
||||
visible_when = "NOT status == 'blacklist'"
|
||||
visible_when = "customer_type == 'enterprise' AND (level == 'vip' OR level == 'svip')"
|
||||
```
|
||||
|
||||
**前端实现:** 新建 `exprEvaluator.ts`,约 100 行递归下降表达式解析器:
|
||||
|
||||
```typescript
|
||||
interface ExprNode {
|
||||
type: 'eq' | 'and' | 'or' | 'not';
|
||||
field?: string;
|
||||
value?: string;
|
||||
left?: ExprNode;
|
||||
right?: ExprNode;
|
||||
operand?: ExprNode;
|
||||
}
|
||||
|
||||
function parseExpr(input: string): ExprNode;
|
||||
function evaluateExpr(node: ExprNode, values: Record<string, unknown>): boolean;
|
||||
```
|
||||
|
||||
不引入外部依赖,不使用 eval。
|
||||
|
||||
### 6.5 Dashboard 图表增强
|
||||
|
||||
**Schema 扩展:** Dashboard 页面支持 widgets 声明:
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "dashboard"
|
||||
label = "统计概览"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "stat_card"
|
||||
entity = "customer"
|
||||
title = "客户总数"
|
||||
icon = "team"
|
||||
color = "#4F46E5"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "bar_chart"
|
||||
entity = "customer"
|
||||
title = "客户地区分布"
|
||||
dimension_field = "region"
|
||||
metric = "count"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "pie_chart"
|
||||
entity = "customer"
|
||||
title = "客户类型分布"
|
||||
dimension_field = "customer_type"
|
||||
metric = "count"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "funnel_chart"
|
||||
entity = "customer"
|
||||
title = "客户等级漏斗"
|
||||
dimension_field = "level"
|
||||
dimension_order = ["potential", "normal", "vip", "svip"]
|
||||
metric = "count"
|
||||
```
|
||||
|
||||
**图表库:** 使用 `@ant-design/charts`(Ant Design 生态一致,支持按需引入)。
|
||||
|
||||
**后端新增:** timeseries 聚合 API:
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/{id}/{entity}/timeseries?time_field=occurred_at&time_grain=week&start=2026-01-01&end=2026-04-17
|
||||
|
||||
响应:{ "data": [{ "period": "2026-W01", "count": 12 }, ...] }
|
||||
```
|
||||
|
||||
SQL 实现:`date_trunc('week', (data->>'occurred_at')::timestamp)`
|
||||
|
||||
**数据钻取:** 图表点击维度值时跳转到 CRUD 页面并自动带上筛选条件。`PluginCRUDPage` 支持从 URL query 参数初始化筛选。
|
||||
|
||||
### 6.6 前端文件拆分
|
||||
|
||||
| 当前文件 | 行数 | 拆分方案 |
|
||||
|---------|------|---------|
|
||||
| `PluginGraphPage.tsx` | 1081 | → `graphRenderer.ts` + `graphLayout.ts` + `graphInteraction.ts` |
|
||||
| `PluginCRUDPage.tsx` | 617 | → `CrudTable.tsx` + `CrudForm.tsx` + `CrudDetail.tsx` |
|
||||
| `PluginDashboardPage.tsx` | 647 | → `DashboardWidgets.tsx` + `dashboardTypes.ts` |
|
||||
|
||||
拆分后每个文件控制在 400 行以内。
|
||||
|
||||
### 6.7 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `apps/web/src/components/EntitySelect.tsx` | 新增 |
|
||||
| `apps/web/src/pages/PluginKanbanPage.tsx` | 新增 |
|
||||
| `apps/web/src/utils/exprEvaluator.ts` | 新增 |
|
||||
| `apps/web/src/pages/PluginCRUDPage.tsx` | 重构 — 拆分 + 批量操作 + entity_select + visible_when |
|
||||
| `apps/web/src/pages/PluginGraphPage.tsx` | 重构 — 拆分 |
|
||||
| `apps/web/src/pages/PluginDashboardPage.tsx` | 重构 — 图表 + 拆分 |
|
||||
| `apps/web/src/pages/PluginTreePage.tsx` | 优化 — 懒加载 |
|
||||
| `apps/web/src/api/plugins.ts` | Schema 类型扩展 |
|
||||
| `apps/web/src/api/pluginData.ts` | 新增 batch / timeseries / cursor API |
|
||||
| `apps/web/src/App.tsx` | Kanban 路由注册 |
|
||||
| `crates/erp-plugin/src/handler/data_handler.rs` | 新增 PATCH / batch 端点 |
|
||||
| `crates/erp-plugin/src/data_service.rs` | batch / timeseries / partial update(PATCH 只合并 data 中的字段) |
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | 新增 `build_patch_sql` 部分更新 SQL 构建器 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与缓解
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| Generated Column 的 ALTER TABLE 锁表 | 中 | 中 | 插件安装时在低峰期执行;万级数据以内锁表时间 < 1s |
|
||||
| pg_trgm 索引空间开销(约 2-3x 原始文本) | 低 | 低 | 只为 searchable 的短文本字段创建 |
|
||||
| 行级权限的部门查询性能 | 中 | 中 | Redis 缓存部门树,TTL 10 分钟 |
|
||||
| 批量操作事务过大 | 低 | 中 | 上限 100 条;超过则分批执行 |
|
||||
| 前端重构引入回归 | 中 | 高 | 逐文件拆分,每步验证现有功能不变 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 不在范围内(后续版本)
|
||||
|
||||
以下内容在本次设计中**不涉及**,记录为已知需求:
|
||||
|
||||
- WASM Guest 业务逻辑增强 (L2/L3 插件模型)
|
||||
- 插件版本升级迁移框架
|
||||
- 跨插件通信 (事件契约 + 只读查询)
|
||||
- 插件间 RPC / 自定义 API 端点
|
||||
- 插件市场 / 分发架构
|
||||
- CRM 新增实体 (lead / opportunity / activity)
|
||||
- WIT 接口版本化
|
||||
- 图谱 LOD + WebGL 渲染
|
||||
- Iframe / Web Component 自定义 UI
|
||||
|
||||
这些将在后续的设计规格中详细展开。
|
||||
Reference in New Issue
Block a user