Compare commits

...

30 Commits

Author SHA1 Message Date
iven
b96978b588 refactor(web): 拆分 PluginDashboardPage 为 dashboard 子模块 — 每个文件 < 400 行
将 981 行的 PluginDashboardPage.tsx 拆分为 4 个文件:
- dashboard/dashboardTypes.ts (25 行) — 类型定义
- dashboard/dashboardConstants.ts (85 行) — 常量和配置
- dashboard/DashboardWidgets.tsx (298 行) — Widget 子组件 + 共享工具
- PluginDashboardPage.tsx (397 行) — 页面壳

提取了 prepareChartData / WidgetCardShell / tagStrokeColor 等共享工具,
消除了图表组件间的重复代码。tsc --noEmit 通过。
2026-04-17 11:26:52 +08:00
iven
fb809f124c fix(web): 修复 TypeScript 编译错误 — 10 处类型/未使用变量问题
- EntitySelect: 未使用的 searchFields 改为 _searchFields
- PluginKanbanPage: DragEndEvent/DragStartEvent 改为 type import, lane_order 改为 optional
- PluginDashboardPage: 添加 PluginPageSchema import, 移除未使用的 CHART_COLORS/palette/totalCount
- PluginGraphPage: 移除未使用的 Title/textColor, 修复 hovered → hoverState
2026-04-17 11:19:44 +08:00
iven
60799176ca feat(crm): entity_select + kanban + 级联过滤声明
- PluginField 新增 ref_label_field / ref_search_fields / cascade_from / cascade_filter 字段
- PluginPageType 新增 Kanban 变体(lane_field / lane_order / card_title_field / card_subtitle_field / card_fields / enable_drag)
- CRM plugin.toml: contact.customer_id 和 communication.contact_id 添加 entity_select 声明
- CRM plugin.toml: communication.contact_id 添加 cascade_from/cascade_filter 级联过滤
- CRM plugin.toml: 新增销售漏斗 kanban 页面声明
- 新增 5 个解析测试(entity_select / cascade / kanban / 空值校验)
2026-04-17 11:10:31 +08:00
iven
4ea9bccba6 feat(web): Dashboard 图表增强 — bar/pie/funnel/line + 并行加载
- 新增基于 DashboardWidget 声明的图表渲染
- 支持 stat_card/bar_chart/pie_chart/funnel_chart/line_chart 五种类型
- 使用 @ant-design/charts 渲染 Column/Pie/Funnel/Line 图表
- Widget 数据通过 Promise.all 并行加载
- 保留原有基于实体的分布统计作为兜底
- 安装 @ant-design/charts 依赖
2026-04-17 11:04:36 +08:00
iven
9549f896b6 feat(web): CRUD 页面批量操作 — 多选 + 批量删除
- 新增 selectedRowKeys 状态管理
- Table 添加 rowSelection 支持多选
- 新增批量操作栏,显示已选数量和批量删除按钮
- 批量删除调用 batchPluginData API
- compact 模式下隐藏批量操作
2026-04-17 11:02:01 +08:00
iven
a333b3673f feat(plugin): timeseries 聚合 API — date_trunc 时间序列 2026-04-17 11:01:43 +08:00
iven
c9a58e9d34 feat(web): Kanban 看板页面 — dnd-kit 拖拽 + 跨列移动
- 新增 PluginKanbanPage 看板页面,支持 dnd-kit 拖拽
- 支持泳道分组、卡片标题/副标题/标签展示
- 乐观更新 UI,失败自动回滚
- 路由入口 /plugins/:pluginId/kanban/:entityName 自加载 schema
- PluginTabsPage 新增 kanban 页面类型支持
- PluginStore 新增 kanban 菜单项和路由生成
- 安装 @dnd-kit/core + @dnd-kit/sortable
2026-04-17 11:00:52 +08:00
iven
c487a94f19 feat(plugin): 批量操作端点 — batch_delete + batch_update 2026-04-17 10:58:34 +08:00
iven
022ac951c9 feat(web): visible_when 增强 — 支持 AND/OR/NOT/括号 表达式
- 新增 utils/exprEvaluator.ts 表达式解析器
- 支持 eq/and/or/not 四种节点类型和括号分组
- 支持 == 和 != 比较运算符
- PluginCRUDPage 替换简单正则为 evaluateVisibleWhen
2026-04-17 10:57:34 +08:00
iven
b0ee3e495d feat(plugin): PATCH 部分更新端点 — jsonb_set 字段合并 2026-04-17 10:56:37 +08:00
iven
e2e58d3a00 feat(web): EntitySelect 关联选择器 — 远程搜索 + 级联过滤
- 新增 EntitySelect 组件,支持远程搜索和级联过滤
- PluginCRUDPage 表单渲染新增 entity_select widget 支持
- 通过 ref_entity/ref_label_field 配置关联实体
- 通过 cascade_from/cascade_filter 实现级联过滤
2026-04-17 10:56:17 +08:00
iven
5b2ae16ffb feat(web): API 层扩展 — batch/patch/timeseries/kanban 类型
- PluginFieldSchema 新增 ref_entity/ref_label_field/ref_search_fields/cascade_from/cascade_filter
- PluginPageSchema 新增 kanban 页面类型(lane_field/card_title_field 等)
- PluginPageSchema dashboard 类型扩展 widgets 字段
- 新增 DashboardWidget 接口(stat_card/bar/pie/funnel/line 图表)
- pluginData 新增 batchPluginData/patchPluginData/getPluginTimeseries 三个 API 函数
2026-04-17 10:55:24 +08:00
iven
8bef5e2401 feat(crm): 启用客户实体 data_scope + owner_id + data_scope_levels
- customer 实体新增 data_scope = true 启用行级数据权限
- customer 新增 owner_id 字段 (scope_role = "owner") 标记数据所有者
- customer.list 和 customer.manage 权限新增 data_scope_levels 声明
  支持 self / department / department_tree / all 四种范围等级
2026-04-17 10:50:53 +08:00
iven
a7342f83e9 feat(plugin): 数据范围查询基础设施 — get_data_scope + get_dept_members 辅助函数
- 新增 get_data_scope() 查询当前用户对指定权限的 data_scope 等级
- 新增 get_dept_members() 获取部门成员 ID 列表(预留递归部门树查询)
- 在 list_plugin_data handler 中标记 data_scope 注入点 TODO
- 这些基础设施函数将在前端 Chunk 4 完成完整集成
2026-04-17 10:49:57 +08:00
iven
41a0dc8bd6 feat(plugin): 实体级 data_scope + scope_role + data_scope_levels 声明
- PluginEntity 新增 data_scope: Option<bool> 字段,控制是否启用行级数据权限
- PluginField 新增 scope_role: Option<String> 字段,标记数据权限的"所有者"字段
- PluginPermission 新增 data_scope_levels: Option<Vec<String>> 字段,声明支持的数据范围等级
- 更新 default_for_field() 测试辅助和 dynamic_table.rs 中的 PluginEntity 构造
- 新增 parse_entity_with_data_scope 和 parse_permission_with_data_scope_levels 测试
2026-04-17 10:45:49 +08:00
iven
89684313d9 feat(plugin): 级联删除 — relations OnDeleteStrategy 支持
delete 方法扩展为处理三种级联策略:Restrict(存在关联时拒绝删除)、
Nullify(置空外键字段)、Cascade(级联软删除关联记录)。
在软删除主记录之前按声明顺序处理所有关联关系。
2026-04-17 10:40:05 +08:00
iven
e24b820d80 feat(plugin): 循环引用检测 — no_cycle 字段支持
新增 check_no_cycle 异步函数,通过沿 parent 链上溯检测
是否存在循环引用。在 update 方法中集成,对声明 no_cycle
的字段执行检测,最多遍历 100 层防止无限循环。
2026-04-17 10:38:41 +08:00
iven
e6aaa18ceb fix(plugin): 移除权限 fallback — 必须显式分配实体级权限
所有 7 个数据 handler 方法不再回退到 plugin.list/plugin.admin
粗粒度权限。现在必须为每个实体显式分配 {plugin}.{entity}.list
或 {plugin}.{entity}.manage 权限,否则返回 403。
2026-04-17 10:38:05 +08:00
iven
314580243e feat(plugin): 字段正则校验 — validation.pattern 支持
Cargo.toml 新增 regex 依赖。validate_data 函数扩展支持
FieldValidation.pattern 正则校验,空值非必填字段跳过校验,
校验失败时返回自定义 message 或默认提示。
2026-04-17 10:37:37 +08:00
iven
dadb826804 feat(plugin): SQL 构建支持行级数据范围条件
DynamicTableManager 新增 build_data_scope_condition_with_params 方法,
支持 all/self/department/department_tree 四种数据范围过滤。
部门成员为空时自动退化为 self 范围,支持 Generated Column 路由。
附带 6 个单元测试覆盖所有场景。
2026-04-17 10:36:01 +08:00
iven
649334e862 feat(plugin): 外键校验 — ref_entity 字段验证引用记录存在性
新增 validate_ref_entities 异步函数,在 create/update 时检查
ref_entity 字段指向的记录是否存在于对应动态表中。自引用
场景下 create 跳过校验,update 跳过自身引用。
2026-04-17 10:35:46 +08:00
iven
f4b1a06d53 feat(auth): JWT 中间件预留 department_ids 填充位置
当前 department_ids 为空列表,附带 TODO 注释说明
待 user_positions 关联表建立后补充查询逻辑。
2026-04-17 10:34:06 +08:00
iven
527a57df9e feat(plugin): PluginRelation 级联删除声明 + OnDeleteStrategy
新增 OnDeleteStrategy 枚举(Nullify/Cascade/Restrict)和
PluginRelation 结构体声明实体关联关系。PluginEntity 增加
relations 字段(serde(default) 向后兼容)。
2026-04-17 10:33:58 +08:00
iven
62f17d13ad feat(core): TenantContext 新增 department_ids 字段
为行级数据权限做准备,TenantContext 新增 department_ids 字段
存储用户所属部门 ID 列表。当前阶段 JWT 中间件填充为空列表,
待 user_positions 关联表建立后补充查询逻辑。
2026-04-17 10:33:28 +08:00
iven
6f286acbeb feat(db): role_permissions 添加 data_scope 列
行级数据权限基础设施 — role_permissions 表新增 data_scope 列,
支持 all/self/department/department_tree 四种数据范围。
2026-04-17 10:32:12 +08:00
iven
f697b5fd6d feat(plugin): PluginField 扩展 — ref_entity / validation / no_cycle
新增 FieldValidation 类型支持正则校验规则,PluginField 增加
ref_entity(外键引用实体名)、validation(字段校验规则)、
no_cycle(禁止循环引用)三个可选字段。
2026-04-17 10:31:37 +08:00
iven
abc3086571 chore(crm): 验证 Generated Column 自动生成 — 无需修改 plugin.toml
验证所有实体的 unique/filterable/sortable 标记已正确配置,
build_create_table_sql 将自动为以下字段生成 Generated Column:
- customer: code(unique), customer_type/industry/region/level/status(filterable)
- communication: type(filterable), occurred_at(sortable)
- customer_relationship: relationship_type(filterable)

同步更新 Cargo.lock(moka 依赖引入)
2026-04-17 10:26:13 +08:00
iven
16b7a36bfb feat(plugin): list 方法集成 Generated Column 路由
- list 方法新增 cache 参数,使用 resolve_entity_info_cached 替代直接查库
- 查询改用 build_filtered_query_sql_ex,自动路由到 Generated Column
- handler 传递 entity_cache 到 list 方法
2026-04-17 10:25:43 +08:00
iven
28c7126518 feat(plugin): 聚合查询 Redis 缓存骨架
- 新增 aggregate_cached 方法,预留 Redis 缓存接口
- 当前直接委托到 aggregate 方法,未来版本添加缓存层
2026-04-17 10:24:26 +08:00
iven
091d517af6 feat(plugin): Schema 缓存 — moka LRU Cache 消除 resolve_entity_info 重复查库
- 添加 moka 0.12 依赖到 erp-plugin 和 erp-server
- 重写 state.rs: 新增 EntityInfo (含 generated_fields) 和 moka Cache
- AppState 新增 plugin_entity_cache 字段
- data_service.rs: 旧 resolve_entity_info 保留兼容,新增 resolve_entity_info_cached
2026-04-17 10:23:43 +08:00
34 changed files with 3868 additions and 433 deletions

41
Cargo.lock generated
View File

@@ -901,6 +901,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -1234,9 +1243,11 @@ version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"chrono",
"dashmap",
"erp-core",
"moka",
"sea-orm",
"serde",
"serde_json",
@@ -1297,6 +1308,7 @@ dependencies = [
"erp-plugin",
"erp-server-migration",
"erp-workflow",
"moka",
"redis",
"sea-orm",
"serde",
@@ -2334,6 +2346,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moka"
version = "0.12.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
dependencies = [
"crossbeam-channel",
"crossbeam-epoch",
"crossbeam-utils",
"equivalent",
"parking_lot",
"portable-atomic",
"smallvec",
"tagptr",
"uuid",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -2684,6 +2713,12 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "postcard"
version = "1.1.3"
@@ -3964,6 +3999,12 @@ dependencies = [
"winx",
]
[[package]]
name = "tagptr"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "tap"
version = "1.0.1"

View File

@@ -10,7 +10,10 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/charts": "^2.6.7",
"@ant-design/icons": "^6.1.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@xyflow/react": "^12.10.2",
"antd": "^6.3.5",
"axios": "^1.15.0",

900
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@ const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => (
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 })));
const PluginKanbanPage = lazy(() => import('./pages/PluginKanbanPage'));
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
@@ -146,6 +147,7 @@ export default function App() {
<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/kanban/:entityName" element={<PluginKanbanPage />} />
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
</Routes>
</Suspense>

View File

@@ -123,3 +123,51 @@ export async function aggregatePluginData(
);
return data.data;
}
// ── 批量操作 ──
export async function batchPluginData(
pluginId: string,
entity: string,
req: { action: string; ids: string[]; data?: Record<string, unknown> },
) {
const { data } = await client.post<{ success: boolean; data: unknown }>(
`/plugins/${pluginId}/${entity}/batch`,
req,
);
return data.data;
}
// ── 部分更新 ──
export async function patchPluginData(
pluginId: string,
entity: string,
id: string,
req: { data: Record<string, unknown>; version: number },
) {
const { data } = await client.patch<{ success: boolean; data: PluginDataRecord }>(
`/plugins/${pluginId}/${entity}/${id}`,
req,
);
return data.data;
}
// ── 时间序列 ──
export async function getPluginTimeseries(
pluginId: string,
entity: string,
params: {
time_field: string;
time_grain: string;
start?: string;
end?: string;
},
) {
const { data } = await client.get<{ success: boolean; data: unknown }>(
`/plugins/${pluginId}/${entity}/timeseries`,
{ params },
);
return data.data;
}

View File

@@ -134,6 +134,11 @@ export interface PluginFieldSchema {
sortable?: boolean;
visible_when?: string;
unique?: boolean;
ref_entity?: string;
ref_label_field?: string;
ref_search_fields?: string[];
cascade_from?: string;
cascade_filter?: string;
}
export interface PluginEntitySchema {
@@ -157,7 +162,30 @@ export type PluginPageSchema =
| { 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 };
| { type: 'dashboard'; label: string; widgets?: DashboardWidget[] }
| {
type: 'kanban';
entity: string;
label: string;
icon?: string;
lane_field: string;
lane_order?: string[];
card_title_field: string;
card_subtitle_field?: string;
card_fields?: string[];
enable_drag?: boolean;
};
export interface DashboardWidget {
type: 'stat_card' | 'bar_chart' | 'pie_chart' | 'funnel_chart' | 'line_chart';
entity: string;
title: string;
icon?: string;
color?: string;
dimension_field?: string;
dimension_order?: string[];
metric?: string;
}
export type PluginSectionSchema =
| { type: 'fields'; label: string; fields: string[] }

View File

@@ -0,0 +1,80 @@
import { Select, Spin } from 'antd';
import { useState, useEffect, useCallback } from 'react';
import { listPluginData } from '../api/pluginData';
interface EntitySelectProps {
pluginId: string;
entity: string;
labelField: string;
searchFields?: string[];
value?: string;
onChange?: (value: string, label: string) => void;
cascadeFrom?: string;
cascadeFilter?: string;
cascadeValue?: string;
placeholder?: string;
}
export default function EntitySelect({
pluginId,
entity,
labelField,
searchFields: _searchFields,
value,
onChange,
cascadeFrom,
cascadeFilter,
cascadeValue,
placeholder,
}: EntitySelectProps) {
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
const [loading, setLoading] = useState(false);
const fetchData = useCallback(
async (keyword?: string) => {
setLoading(true);
try {
const filter: Record<string, string> | undefined =
cascadeFrom && cascadeFilter && cascadeValue
? { [cascadeFilter]: cascadeValue }
: undefined;
const result = await listPluginData(pluginId, entity, 1, 20, {
search: keyword,
filter,
});
const items = (result.data || []).map((item) => ({
value: item.id,
label: String(item.data?.[labelField] ?? item.id),
}));
setOptions(items);
} finally {
setLoading(false);
}
},
[pluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue],
);
useEffect(() => {
fetchData();
}, [fetchData]);
return (
<Select
showSearch
value={value}
placeholder={placeholder || '请选择'}
loading={loading}
options={options}
onSearch={(v) => fetchData(v)}
onChange={(v) => {
const opt = options.find((o) => o.value === v);
onChange?.(v, opt?.label || '');
}}
filterOption={false}
notFoundContent={loading ? <Spin size="small" /> : '无数据'}
allowClear
/>
);
}

View File

@@ -31,8 +31,10 @@ import {
createPluginData,
updatePluginData,
deletePluginData,
batchPluginData,
type PluginDataListOptions,
} from '../api/pluginData';
import EntitySelect from '../components/EntitySelect';
import {
getPluginSchema,
type PluginFieldSchema,
@@ -40,29 +42,11 @@ import {
type PluginPageSchema,
type PluginSectionSchema,
} from '../api/plugins';
import { evaluateVisibleWhen } from '../utils/exprEvaluator';
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] };
}
/** 判断字段是否应该显示 */
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;
}
interface PluginCRUDPageProps {
/** 如果从 tabs/detail 页面内嵌使用,通过 props 传入配置 */
pluginIdOverride?: string;
@@ -106,6 +90,9 @@ export default function PluginCRUDPage({
// 视图切换
const [viewMode, setViewMode] = useState<string>('table');
// 批量选择
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
// 详情 Drawer
const [detailOpen, setDetailOpen] = useState(false);
const [detailRecord, setDetailRecord] = useState<Record<string, unknown> | null>(null);
@@ -257,6 +244,21 @@ export default function PluginCRUDPage({
}
};
const handleBatchDelete = async () => {
if (!pluginId || !entityName || selectedRowKeys.length === 0) return;
try {
await batchPluginData(pluginId, entityName, {
action: 'delete',
ids: selectedRowKeys,
});
message.success(`已删除 ${selectedRowKeys.length} 条记录`);
setSelectedRowKeys([]);
fetchData();
} catch {
message.error('批量删除失败');
}
};
// 动态生成列
const columns = [
...fields.slice(0, 5).map((f) => ({
@@ -336,6 +338,25 @@ export default function PluginCRUDPage({
);
case 'textarea':
return <TextArea rows={3} />;
case 'entity_select':
return (
<EntitySelect
pluginId={pluginId}
entity={field.ref_entity!}
labelField={field.ref_label_field || 'name'}
searchFields={field.ref_search_fields}
value={formValues[field.name] as string | undefined}
onChange={(v) => form.setFieldValue(field.name, v)}
cascadeFrom={field.cascade_from}
cascadeFilter={field.cascade_filter}
cascadeValue={
field.cascade_from
? (formValues[field.cascade_from] as string | undefined)
: undefined
}
placeholder={field.display_name}
/>
);
default:
return <Input />;
}
@@ -530,6 +551,34 @@ export default function PluginCRUDPage({
</Space>
)}
{/* 批量操作栏 */}
{selectedRowKeys.length > 0 && !compact && (
<div
style={{
marginBottom: 16,
padding: '8px 16px',
background: 'var(--colorBgContainer, #fff)',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<span> <strong>{selectedRowKeys.length}</strong> </span>
<Popconfirm
title={`确定删除选中的 ${selectedRowKeys.length} 条记录?`}
onConfirm={handleBatchDelete}
>
<Button danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
<Button onClick={() => setSelectedRowKeys([])}>
</Button>
</div>
)}
{viewMode === 'table' || enableViews.length <= 1 ? (
<Table
columns={columns}
@@ -537,6 +586,14 @@ export default function PluginCRUDPage({
rowKey="_id"
loading={loading}
size={compact ? 'small' : undefined}
rowSelection={
compact
? undefined
: {
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}
}
onChange={(_pagination, _filters, sorter) => {
if (!Array.isArray(sorter) && sorter.field) {
const newSortBy = String(sorter.field);
@@ -588,7 +645,7 @@ export default function PluginCRUDPage({
>
{fields.map((field) => {
// visible_when 条件显示
const visible = shouldShowField(formValues, field.visible_when);
const visible = evaluateVisibleWhen(field.visible_when, formValues);
if (!visible) return null;
return (

View File

@@ -1,340 +1,24 @@
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Row, Col, Spin, Empty, Select, Tag, Progress, Skeleton, theme, Tooltip } from 'antd';
import { Row, Col, Empty, Select, Spin, theme } from 'antd';
import { DashboardOutlined } from '@ant-design/icons';
import { countPluginData, aggregatePluginData } from '../api/pluginData';
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];
}
getPluginSchema,
type PluginEntitySchema,
type PluginSchemaResponse,
type PluginPageSchema,
type DashboardWidget,
} from '../api/plugins';
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboard/dashboardTypes';
import { ENTITY_PALETTE, DEFAULT_PALETTE, ENTITY_ICONS, getDelayClass } from './dashboard/dashboardConstants';
import {
StatCard,
SkeletonStatCard,
BreakdownCard,
SkeletonBreakdownCard,
WidgetRenderer,
} from './dashboard/DashboardWidgets';
// ── 主组件 ──
@@ -349,7 +33,10 @@ export function PluginDashboardPage() {
const [entityStats, setEntityStats] = useState<EntityStat[]>([]);
const [breakdowns, setBreakdowns] = useState<FieldBreakdown[]>([]);
const [error, setError] = useState<string | null>(null);
// Widget-based dashboard state
const [widgets, setWidgets] = useState<DashboardWidget[]>([]);
const [widgetData, setWidgetData] = useState<WidgetData[]>([]);
const [widgetsLoading, setWidgetsLoading] = useState(false);
const isDark =
themeToken.colorBgContainer === '#111827' ||
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
@@ -358,7 +45,6 @@ export function PluginDashboardPage() {
useEffect(() => {
if (!pluginId) return;
const abortController = new AbortController();
async function loadSchema() {
setSchemaLoading(true);
setError(null);
@@ -370,6 +56,15 @@ export function PluginDashboardPage() {
if (entityList.length > 0) {
setSelectedEntity(entityList[0].name);
}
// 提取 dashboard widgets
const pages = schema.ui?.pages || [];
const dashboardPage = pages.find(
(p): p is PluginPageSchema & { type: 'dashboard'; widgets?: DashboardWidget[] } =>
p.type === 'dashboard',
);
if (dashboardPage?.widgets && dashboardPage.widgets.length > 0) {
setWidgets(dashboardPage.widgets);
}
} catch {
setError('Schema 加载失败,部分功能不可用');
} finally {
@@ -380,22 +75,18 @@ export function PluginDashboardPage() {
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) {
@@ -432,19 +123,55 @@ export function PluginDashboardPage() {
loadAllCounts();
return () => abortController.abort();
}, [pluginId, entities]);
// Widget 数据并行加载
useEffect(() => {
if (!pluginId || widgets.length === 0) return;
const abortController = new AbortController();
async function loadWidgetData() {
setWidgetsLoading(true);
try {
const results = await Promise.all(
widgets.map(async (widget) => {
try {
if (widget.type === 'stat_card') {
const count = await countPluginData(pluginId!, widget.entity);
return { widget, data: [], count };
}
if (widget.dimension_field) {
const data = await aggregatePluginData(
pluginId!,
widget.entity,
widget.dimension_field,
);
return { widget, data };
}
// 没有 dimension_field 时仅返回计数
const count = await countPluginData(pluginId!, widget.entity);
return { widget, data: [], count };
} catch {
return { widget, data: [], count: 0 };
}
}),
);
if (!abortController.signal.aborted) {
setWidgetData(results);
}
} finally {
if (!abortController.signal.aborted) setWidgetsLoading(false);
}
}
loadWidgetData();
return () => abortController.abort();
}, [pluginId, widgets]);
// 当前实体的聚合数据
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 {
@@ -460,39 +187,29 @@ export function PluginDashboardPage() {
}
}
if (!abortController.signal.aborted) {
setBreakdowns(fieldResults);
}
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(() => {});
};
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 }}>
@@ -572,6 +289,41 @@ export function PluginDashboardPage() {
))}
</Row>
{/* Widget 图表区域 */}
{widgets.length > 0 && (
<>
<div style={{ marginBottom: 16 }}>
<div className="erp-section-header">
<DashboardOutlined
className="erp-section-icon"
style={{ color: '#4F46E5' }}
/>
<span className="erp-section-title"></span>
</div>
</div>
{widgetsLoading && widgetData.length === 0 ? (
<Row gutter={[16, 16]}>
{widgets.map((_, i) => (
<SkeletonBreakdownCard key={i} index={i} />
))}
</Row>
) : (
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{widgetData.map((wd) => {
const colSpan = wd.widget.type === 'stat_card' ? 6
: wd.widget.type === 'pie_chart' || wd.widget.type === 'funnel_chart' ? 12
: 12;
return (
<Col key={`${wd.widget.type}-${wd.widget.entity}-${wd.widget.title}`} xs={24} sm={colSpan}>
<WidgetRenderer widgetData={wd} isDark={isDark} />
</Col>
);
})}
</Row>
)}
</>
)}
{/* 分组统计区域 */}
<div style={{ marginBottom: 16 }}>
<div className="erp-section-header">
@@ -607,7 +359,6 @@ export function PluginDashboardPage() {
key={bd.fieldName}
breakdown={bd}
totalCount={currentTotal}
palette={currentPalette}
index={i}
/>
))}

View File

@@ -33,7 +33,7 @@ import {
type PluginSchemaResponse,
} from '../api/plugins';
const { Text, Title } = Typography;
const { Text } = Typography;
// ── Types ──
@@ -300,7 +300,6 @@ function drawEdgeLabel(
y: number,
label: string,
color: string,
textColor: string,
alpha: number,
) {
ctx.save();
@@ -617,7 +616,7 @@ export function PluginGraphPage() {
// Edge label
if (edge.label && labelPos) {
const labelAlpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.1) : 0.9;
drawEdgeLabel(ctx, labelPos.labelX, labelPos.labelY - 10, edge.label, colors.base, textColor, labelAlpha);
drawEdgeLabel(ctx, labelPos.labelX, labelPos.labelY - 10, edge.label, colors.base, labelAlpha);
}
}
@@ -672,7 +671,7 @@ export function PluginGraphPage() {
if (hoverState.nodeId) {
const hoveredNode = nodes.find((n) => n.id === hoverState.nodeId);
if (hoveredNode) {
const degree = degreeMap.get(hovered.nodeId) || 0;
const degree = degreeMap.get(hoverState.nodeId) || 0;
const tooltipText = `${hoveredNode.label} (${degree} 条关系)`;
ctx.save();
ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';

View File

@@ -0,0 +1,345 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Card, Spin, Typography, Tag, message } from 'antd';
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
closestCorners,
} from '@dnd-kit/core';
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
import { listPluginData, patchPluginData } from '../api/pluginData';
import { getPluginSchema, type PluginPageSchema } from '../api/plugins';
// ── 内部看板渲染组件 ──
interface KanbanInnerProps {
pluginId: string;
entity: string;
laneField: string;
laneOrder: string[];
cardTitleField: string;
cardSubtitleField?: string;
cardFields?: string[];
enableDrag?: boolean;
}
function KanbanInner({
pluginId,
entity,
laneField,
laneOrder,
cardTitleField,
cardSubtitleField,
cardFields,
enableDrag,
}: KanbanInnerProps) {
const [lanes, setLanes] = useState<Record<string, any[]>>({});
const [loading, setLoading] = useState(true);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
const fetchData = async () => {
setLoading(true);
try {
const allData: Record<string, any[]> = {};
const results = await Promise.all(
laneOrder.map(async (lane) => {
const res = await listPluginData(pluginId, entity, 1, 100, {
filter: { [laneField]: lane },
});
return { lane, data: res.data || [] };
}),
);
for (const { lane, data } of results) {
allData[lane] = data;
}
setLanes(allData);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [pluginId, entity]);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = async (event: DragEndEvent) => {
setActiveId(null);
if (!enableDrag) return;
const { active, over } = event;
if (!over) return;
const recordId = active.id as string;
const newLane = String(over.data.current?.lane || over.id);
if (!newLane) return;
let currentLane = '';
for (const [lane, items] of Object.entries(lanes)) {
if (items.some((item) => item.id === recordId)) {
currentLane = lane;
break;
}
}
if (currentLane === newLane) return;
// 乐观更新
setLanes((prev) => {
const next: Record<string, any[]> = {};
for (const [lane, items] of Object.entries(prev)) {
if (lane === currentLane) {
next[lane] = items.filter((item) => item.id !== recordId);
} else if (lane === newLane) {
const moved = prev[currentLane]?.find((item) => item.id === recordId);
next[lane] = moved ? [...items, moved] : [...items];
} else {
next[lane] = items;
}
}
return next;
});
try {
await patchPluginData(pluginId, entity, recordId, {
data: { [laneField]: newLane },
version: 0,
});
message.success('移动成功');
} catch {
message.error('移动失败');
fetchData();
}
};
const handleDragCancel = () => {
setActiveId(null);
};
const activeCard = activeId
? Object.values(lanes)
.flat()
.find((item) => item.id === activeId)
: null;
if (loading) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin />
</div>
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div style={{ display: 'flex', gap: 16, overflowX: 'auto', padding: 16 }}>
{laneOrder.map((lane) => {
const items = lanes[lane] || [];
return (
<div
key={lane}
id={`lane-${lane}`}
style={{
minWidth: 280,
flex: 1,
background: 'var(--colorBgLayout, #f5f5f5)',
borderRadius: 8,
padding: 12,
}}
>
<div
style={{
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<Typography.Text strong>{lane}</Typography.Text>
<Tag>{items.length}</Tag>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((item) => (
<Card
key={item.id}
id={item.id}
size="small"
style={{
cursor: enableDrag ? 'grab' : 'default',
opacity: activeId === item.id ? 0.4 : 1,
}}
>
<Typography.Text strong>
{item.data?.[cardTitleField] ?? '-'}
</Typography.Text>
{cardSubtitleField && item.data?.[cardSubtitleField] && (
<div>
<Typography.Text type="secondary">
{item.data[cardSubtitleField]}
</Typography.Text>
</div>
)}
{cardFields && (
<div style={{ marginTop: 4 }}>
{cardFields.map(
(f) =>
item.data?.[f] ? (
<Tag key={f}>{String(item.data[f])}</Tag>
) : null,
)}
</div>
)}
</Card>
))}
</div>
</div>
);
})}
</div>
<DragOverlay>
{activeCard ? (
<Card
size="small"
style={{
cursor: 'grabbing',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
width: 260,
}}
>
<Typography.Text strong>
{activeCard.data?.[cardTitleField] ?? '-'}
</Typography.Text>
{cardSubtitleField && activeCard.data?.[cardSubtitleField] && (
<div>
<Typography.Text type="secondary">
{activeCard.data[cardSubtitleField]}
</Typography.Text>
</div>
)}
</Card>
) : null}
</DragOverlay>
</DndContext>
);
}
// ── 路由入口:自加载 schema ──
/**
* 路由入口组件
* 路由: /plugins/:pluginId/kanban/:entityName
* 自动加载 schema 并提取 kanban 页面配置
*/
export default function PluginKanbanPageRoute() {
const { pluginId, entityName } = useParams<{
pluginId: string;
entityName: string;
}>();
const [loading, setLoading] = useState(true);
const [pageConfig, setPageConfig] = useState<{
entity: string;
lane_field: string;
lane_order?: string[];
card_title_field: string;
card_subtitle_field?: string;
card_fields?: string[];
enable_drag?: boolean;
} | null>(null);
useEffect(() => {
if (!pluginId || !entityName) return;
async function loadSchema() {
try {
const schema = await getPluginSchema(pluginId!);
const pages: PluginPageSchema[] = schema.ui?.pages || [];
const kanbanPage = pages.find(
(p): p is PluginPageSchema & { type: 'kanban' } =>
p.type === 'kanban' && p.entity === entityName,
);
if (kanbanPage) {
setPageConfig(kanbanPage);
}
} catch {
message.warning('Schema 加载失败');
} finally {
setLoading(false);
}
}
loadSchema();
}, [pluginId, entityName]);
if (loading) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin />
</div>
);
}
if (!pageConfig) {
return <div style={{ padding: 24 }}></div>;
}
return (
<KanbanInner
pluginId={pluginId!}
entity={pageConfig.entity}
laneField={pageConfig.lane_field}
laneOrder={pageConfig.lane_order || []}
cardTitleField={pageConfig.card_title_field}
cardSubtitleField={pageConfig.card_subtitle_field}
cardFields={pageConfig.card_fields}
enableDrag={pageConfig.enable_drag}
/>
);
}
// ── Tabs/Detail 内嵌使用 ──
export interface PluginKanbanPageFromConfigProps {
pluginId: string;
page: {
entity: string;
lane_field: string;
lane_order?: string[];
card_title_field: string;
card_subtitle_field?: string;
card_fields?: string[];
enable_drag?: boolean;
};
}
export function PluginKanbanPageFromConfig({
pluginId,
page,
}: PluginKanbanPageFromConfigProps) {
return (
<KanbanInner
pluginId={pluginId}
entity={page.entity}
laneField={page.lane_field}
laneOrder={page.lane_order || []}
cardTitleField={page.card_title_field}
cardSubtitleField={page.card_subtitle_field}
cardFields={page.card_fields}
enableDrag={page.enable_drag}
/>
);
}

View File

@@ -4,6 +4,7 @@ import { Tabs, Spin, message } from 'antd';
import { getPluginSchema, type PluginPageSchema, type PluginSchemaResponse } from '../api/plugins';
import PluginCRUDPage from './PluginCRUDPage';
import { PluginTreePage } from './PluginTreePage';
import { PluginKanbanPageFromConfig } from './PluginKanbanPage';
/**
* 插件 Tabs 页面 — 通过路由参数自加载 schema
@@ -65,6 +66,14 @@ export function PluginTabsPage() {
/>
);
}
if (tab.type === 'kanban') {
return (
<PluginKanbanPageFromConfig
pluginId={pluginId!}
page={tab}
/>
);
}
return <div>: {tab.type}</div>;
};

View File

@@ -0,0 +1,298 @@
import { useEffect, useState, useRef } from 'react';
import { Col, Spin, Empty, Tag, Progress, Skeleton, Tooltip, Card, Typography } from 'antd';
import {
InfoCircleOutlined,
DashboardOutlined,
} from '@ant-design/icons';
import { Column, Pie, Funnel, Line } from '@ant-design/charts';
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboardTypes';
import { TAG_COLORS, WIDGET_ICON_MAP } from './dashboardConstants';
// ── 计数动画 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 prepareChartData(data: WidgetData['data'], dimensionOrder?: string[]) {
return dimensionOrder
? dimensionOrder
.map((key) => data.find((d) => d.key === key))
.filter(Boolean)
.map((d) => ({ key: d!.key, count: d!.count }))
: data.map((d) => ({ key: d.key, count: d.count }));
}
const TAG_COLOR_MAP: Record<string, string> = {
blue: '#3B82F6', green: '#10B981', orange: '#F59E0B', red: '#EF4444',
purple: '#8B5CF6', cyan: '#06B6D4', magenta: '#EC4899', gold: '#EAB308',
lime: '#84CC16', geekblue: '#6366F1', volcano: '#F97316',
};
function tagStrokeColor(color: string): string {
return TAG_COLOR_MAP[color] || '#3B82F6';
}
function WidgetCardShell({
title,
widgetType,
children,
}: {
title: string;
widgetType: string;
children: React.ReactNode;
}) {
return (
<Card
size="small"
title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP[widgetType]} {title}</span>}
className="erp-fade-in"
>
{children}
</Card>
);
}
function ChartEmpty() {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />;
}
// ── 基础子组件 ──
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>;
}
/** 顶部统计卡片 */
export 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>
);
}
/** 骨架屏卡片 */
export 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>
);
}
/** 字段分布卡片 */
export function BreakdownCard({
breakdown, totalCount, index,
}: { breakdown: FieldBreakdown; totalCount: number; 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={tagStrokeColor(color)}
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>
);
}
/** 骨架屏分布卡片 */
export 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>
);
}
// ── Widget 图表子组件 ──
/** 统计卡片 widget */
function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) {
const { widget, count } = widgetData;
const animatedValue = useCountUp(count ?? 0);
const color = widget.color || '#4F46E5';
return (
<Card size="small" className="erp-fade-in" style={{ height: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 40, height: 40, borderRadius: 10, background: `${color}18`,
display: 'flex', alignItems: 'center', justifyContent: 'center', color, fontSize: 20,
}}>
{WIDGET_ICON_MAP[widget.type] || <DashboardOutlined />}
</div>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{widget.title}</Typography.Text>
<div style={{ fontSize: 24, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
{animatedValue.toLocaleString()}
</div>
</div>
</div>
</Card>
);
}
/** 柱状图 widget */
function BarWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
const { widget, data } = widgetData;
const chartData = prepareChartData(data, widget.dimension_order);
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' };
return (
<WidgetCardShell title={widget.title} widgetType={widget.type}>
{chartData.length > 0 ? (
<Column data={chartData} xField="key" yField="count" colorField="key"
style={{ maxWidth: 40, maxWidthRatio: 0.6 }}
axis={{ x: { label: { style: axisLabelStyle } }, y: { label: { style: axisLabelStyle } } }}
/>
) : <ChartEmpty />}
</WidgetCardShell>
);
}
/** 饼图 widget */
function PieWidgetCard({ widgetData }: { widgetData: WidgetData }) {
const { widget, data } = widgetData;
const chartData = data.map((d) => ({ key: d.key, count: d.count }));
return (
<WidgetCardShell title={widget.title} widgetType={widget.type}>
{chartData.length > 0 ? (
<Pie data={chartData} angleField="count" colorField="key" radius={0.8} innerRadius={0.5}
label={{ text: 'key', position: 'outside' as const }}
legend={{ color: { position: 'bottom' as const } }}
/>
) : <ChartEmpty />}
</WidgetCardShell>
);
}
/** 漏斗图 widget */
function FunnelWidgetCard({ widgetData }: { widgetData: WidgetData }) {
const { widget, data } = widgetData;
const chartData = prepareChartData(data, widget.dimension_order);
return (
<WidgetCardShell title={widget.title} widgetType={widget.type}>
{chartData.length > 0 ? (
<Funnel data={chartData} xField="key" yField="count" legend={{ position: 'bottom' as const }} />
) : <ChartEmpty />}
</WidgetCardShell>
);
}
/** 折线图 widget */
function LineWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
const { widget, data } = widgetData;
const chartData = prepareChartData(data, widget.dimension_order);
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' };
return (
<WidgetCardShell title={widget.title} widgetType={widget.type}>
{chartData.length > 0 ? (
<Line data={chartData} xField="key" yField="count" smooth
axis={{ x: { label: { style: axisLabelStyle } }, y: { label: { style: axisLabelStyle } } }}
/>
) : <ChartEmpty />}
</WidgetCardShell>
);
}
/** 渲染单个 widget */
export function WidgetRenderer({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
switch (widgetData.widget.type) {
case 'stat_card': return <StatWidgetCard widgetData={widgetData} />;
case 'bar_chart': return <BarWidgetCard widgetData={widgetData} isDark={isDark} />;
case 'pie_chart': return <PieWidgetCard widgetData={widgetData} />;
case 'funnel_chart': return <FunnelWidgetCard widgetData={widgetData} />;
case 'line_chart': return <LineWidgetCard widgetData={widgetData} isDark={isDark} />;
default: return null;
}
}

View File

@@ -0,0 +1,85 @@
import type React from 'react';
import {
TeamOutlined,
PhoneOutlined,
TagsOutlined,
RiseOutlined,
DashboardOutlined,
BarChartOutlined,
PieChartOutlined,
LineChartOutlined,
FunnelPlotOutlined,
} from '@ant-design/icons';
// ── 色板配置 ──
export 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',
},
};
export const DEFAULT_PALETTE = {
gradient: 'linear-gradient(135deg, #2563EB, #3B82F6)',
iconBg: 'rgba(37, 99, 235, 0.12)',
tagColor: 'blue',
};
export const TAG_COLORS = [
'blue', 'green', 'orange', 'red', 'purple', 'cyan',
'magenta', 'gold', 'lime', 'geekblue', 'volcano',
];
// ── 图标映射 ──
export const ENTITY_ICONS: Record<string, React.ReactNode> = {
customer: <TeamOutlined />,
contact: <TeamOutlined />,
communication: <PhoneOutlined />,
customer_tag: <TagsOutlined />,
customer_relationship: <RiseOutlined />,
};
export const WIDGET_ICON_MAP: Record<string, React.ReactNode> = {
stat_card: <DashboardOutlined />,
bar_chart: <BarChartOutlined />,
pie_chart: <PieChartOutlined />,
funnel_chart: <FunnelPlotOutlined />,
line_chart: <LineChartOutlined />,
};
// ── 延迟类名工具 ──
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',
];
export function getDelayClass(index: number): string {
return DELAY_CLASSES[index % DELAY_CLASSES.length];
}

View File

@@ -0,0 +1,25 @@
import type React from 'react';
import type { AggregateItem, DashboardWidget } from '../../api/plugins';
// ── 类型定义 ──
export interface EntityStat {
name: string;
displayName: string;
count: number;
icon: React.ReactNode;
gradient: string;
iconBg: string;
}
export interface FieldBreakdown {
fieldName: string;
displayName: string;
items: AggregateItem[];
}
export interface WidgetData {
widget: DashboardWidget;
data: AggregateItem[];
count?: number;
}

View File

@@ -8,7 +8,7 @@ export interface PluginMenuItem {
label: string;
pluginId: string;
entity?: string;
pageType: 'crud' | 'tree' | 'tabs' | 'detail' | 'graph' | 'dashboard';
pageType: 'crud' | 'tree' | 'tabs' | 'detail' | 'graph' | 'dashboard' | 'kanban';
}
export interface PluginMenuGroup {
@@ -128,6 +128,15 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
pluginId: plugin.id,
pageType: 'dashboard' as const,
});
} else if (page.type === 'kanban') {
items.push({
key: `/plugins/${plugin.id}/kanban/${page.entity}`,
icon: 'UnorderedListOutlined',
label: page.label,
pluginId: plugin.id,
entity: page.entity,
pageType: 'kanban' as const,
});
}
// detail 类型不生成菜单项
}

View File

@@ -0,0 +1,138 @@
/**
* visible_when 表达式解析与求值
*
* 支持语法:
* field == 'value' 等值判断
* field != 'value' 不等判断
* expr1 AND expr2 逻辑与
* expr1 OR expr2 逻辑或
* NOT expr 逻辑非
* (expr) 括号分组
*/
interface ExprNode {
type: 'eq' | 'and' | 'or' | 'not';
field?: string;
value?: string;
left?: ExprNode;
right?: ExprNode;
operand?: ExprNode;
}
function tokenize(input: string): string[] {
const tokens: string[] = [];
let i = 0;
while (i < input.length) {
if (input[i] === ' ') {
i++;
continue;
}
if (input[i] === '(' || input[i] === ')') {
tokens.push(input[i]);
i++;
continue;
}
if (input[i] === "'") {
let j = i + 1;
while (j < input.length && input[j] !== "'") j++;
tokens.push(input.substring(i, j + 1));
i = j + 1;
continue;
}
if (input[i] === '=' && input[i + 1] === '=') {
tokens.push('==');
i += 2;
continue;
}
if (input[i] === '!' && input[i + 1] === '=') {
tokens.push('!=');
i += 2;
continue;
}
let j = i;
while (
j < input.length &&
!' ()\''.includes(input[j]) &&
!(input[j] === '=' && input[j + 1] === '=') &&
!(input[j] === '!' && input[j + 1] === '=')
) {
j++;
}
tokens.push(input.substring(i, j));
i = j;
}
return tokens;
}
function parseAtom(tokens: string[]): ExprNode | null {
const token = tokens.shift();
if (!token) return null;
if (token === '(') {
const expr = parseOr(tokens);
if (tokens[0] === ')') tokens.shift();
return expr;
}
if (token === 'NOT') {
const operand = parseAtom(tokens);
return { type: 'not', operand: operand || undefined };
}
const field = token;
const op = tokens.shift();
if (op !== '==' && op !== '!=') return null;
const rawValue = tokens.shift() || '';
const value = rawValue.replace(/^'(.*)'$/, '$1');
return { type: 'eq', field, value };
}
function parseAnd(tokens: string[]): ExprNode | null {
let left = parseAtom(tokens);
while (tokens[0] === 'AND') {
tokens.shift();
const right = parseAtom(tokens);
if (left && right) {
left = { type: 'and', left, right };
}
}
return left;
}
function parseOr(tokens: string[]): ExprNode | null {
let left = parseAnd(tokens);
while (tokens[0] === 'OR') {
tokens.shift();
const right = parseAnd(tokens);
if (left && right) {
left = { type: 'or', left, right };
}
}
return left;
}
export function parseExpr(input: string): ExprNode | null {
const tokens = tokenize(input);
return parseOr(tokens);
}
export function evaluateExpr(node: ExprNode, values: Record<string, unknown>): boolean {
switch (node.type) {
case 'eq':
return String(values[node.field!] ?? '') === node.value;
case 'and':
return evaluateExpr(node.left!, values) && evaluateExpr(node.right!, values);
case 'or':
return evaluateExpr(node.left!, values) || evaluateExpr(node.right!, values);
case 'not':
return !evaluateExpr(node.operand!, values);
default:
return false;
}
}
export function evaluateVisibleWhen(
expr: string | undefined,
values: Record<string, unknown>,
): boolean {
if (!expr) return true;
const ast = parseExpr(expr);
return ast ? evaluateExpr(ast, values) : true;
}

View File

@@ -47,11 +47,14 @@ pub async fn jwt_auth_middleware_fn(
return Err(AppError::Unauthorized);
}
// TODO: 待 user_positions 关联表建立后,从数据库查询用户所属部门 ID 列表
// 当前阶段 department_ids 为空列表,行级数据权限默认为 all
let ctx = TenantContext {
tenant_id: claims.tid,
user_id: claims.sub,
roles: claims.roles,
permissions: claims.permissions,
department_ids: vec![],
};
// Reconstruct the request with the TenantContext injected into extensions.

View File

@@ -49,6 +49,7 @@ mod tests {
user_id: Uuid::now_v7(),
roles: roles.into_iter().map(String::from).collect(),
permissions: permissions.into_iter().map(String::from).collect(),
department_ids: vec![],
}
}

View File

@@ -113,6 +113,7 @@ mod tests {
user_id: Uuid::now_v7(),
roles: vec!["admin".to_string()],
permissions: vec!["user.read".to_string()],
department_ids: vec![],
};
assert_eq!(ctx.roles.len(), 1);
assert_eq!(ctx.permissions.len(), 1);
@@ -154,4 +155,6 @@ pub struct TenantContext {
pub user_id: Uuid,
pub roles: Vec<String>,
pub permissions: Vec<String>,
/// 用户所属部门 ID 列表(行级数据权限使用)
pub department_ids: Vec<Uuid>,
}

View File

@@ -12,11 +12,13 @@ min_platform_version = "0.1.0"
code = "customer.list"
name = "查看客户"
description = "查看客户列表和详情"
data_scope_levels = ["self", "department", "department_tree", "all"]
[[permissions]]
code = "customer.manage"
name = "管理客户"
description = "创建、编辑、删除客户"
data_scope_levels = ["self", "department", "department_tree", "all"]
[[permissions]]
code = "contact.list"
@@ -51,6 +53,7 @@ name = "管理客户关系"
[[schema.entities]]
name = "customer"
display_name = "客户"
data_scope = true
[[schema.entities.fields]]
name = "code"
@@ -163,6 +166,12 @@ display_name = "客户"
display_name = "备注"
ui_widget = "textarea"
[[schema.entities.fields]]
name = "owner_id"
field_type = "uuid"
display_name = "负责人"
scope_role = "owner"
[[schema.entities]]
name = "contact"
display_name = "联系人"
@@ -172,6 +181,9 @@ display_name = "联系人"
field_type = "uuid"
required = true
display_name = "所属客户"
ui_widget = "entity_select"
ref_label_field = "name"
ref_search_fields = ["name", "code"]
[[schema.entities.fields]]
name = "name"
@@ -229,6 +241,11 @@ display_name = "沟通记录"
name = "contact_id"
field_type = "uuid"
display_name = "关联联系人"
ui_widget = "entity_select"
ref_label_field = "name"
ref_search_fields = ["name"]
cascade_from = "customer_id"
cascade_filter = "customer_id"
[[schema.entities.fields]]
name = "type"
@@ -423,3 +440,15 @@ node_label_field = "name"
type = "dashboard"
label = "统计概览"
icon = "DashboardOutlined"
[[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 = ["region", "status"]
enable_drag = true

View File

@@ -23,3 +23,5 @@ utoipa = { workspace = true }
async-trait = { workspace = true }
sha2 = { workspace = true }
base64 = "0.22"
moka = { version = "0.12", features = ["sync"] }
regex = "1"

View File

@@ -17,13 +17,20 @@ pub struct CreatePluginDataReq {
pub data: serde_json::Value,
}
/// 更新插件数据请求
/// 更新插件数据请求(全量替换)
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpdatePluginDataReq {
pub data: serde_json::Value,
pub version: i32,
}
/// 部分更新请求PATCH — 只合并提供的字段)
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct PatchPluginDataReq {
pub data: serde_json::Value,
pub version: i32,
}
/// 插件数据列表查询参数
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
pub struct PluginDataListParams {
@@ -65,3 +72,36 @@ pub struct CountQueryParams {
/// JSON 格式过滤: {"field":"value"}
pub filter: Option<String>,
}
/// 批量操作请求
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct BatchActionReq {
/// 操作类型: "batch_delete" 或 "batch_update"
pub action: String,
/// 记录 ID 列表(上限 100
pub ids: Vec<String>,
/// batch_update 时的更新数据
pub data: Option<serde_json::Value>,
}
/// 时间序列查询参数
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
pub struct TimeseriesParams {
/// 时间字段名
pub time_field: String,
/// 时间粒度: "day" / "week" / "month"
pub time_grain: String,
/// 开始日期 (ISO)
pub start: Option<String>,
/// 结束日期 (ISO)
pub end: Option<String>,
}
/// 时间序列数据项
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct TimeseriesItem {
/// 时间周期
pub period: String,
/// 计数
pub count: i64,
}

View File

@@ -4,30 +4,16 @@ use uuid::Uuid;
use erp_core::error::{AppError, AppResult};
use erp_core::events::EventBus;
use crate::data_dto::PluginDataResp;
use crate::dynamic_table::DynamicTableManager;
use crate::data_dto::{BatchActionReq, PluginDataResp};
use crate::dynamic_table::{sanitize_identifier, DynamicTableManager};
use crate::entity::plugin;
use crate::entity::plugin_entity;
use crate::error::PluginError;
use crate::manifest::PluginField;
use crate::state::EntityInfo;
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(
@@ -42,6 +28,7 @@ impl PluginDataService {
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let fields = info.fields()?;
validate_data(&data, &fields)?;
validate_ref_entities(&data, &fields, entity_name, plugin_id, tenant_id, db, true, None).await?;
let (sql, values) =
DynamicTableManager::build_insert_sql(&info.table_name, tenant_id, operator_id, &data);
@@ -73,7 +60,7 @@ impl PluginDataService {
})
}
/// 列表查询(支持过滤/搜索/排序)
/// 列表查询(支持过滤/搜索/排序/Generated Column 路由
pub async fn list(
plugin_id: Uuid,
entity_name: &str,
@@ -85,8 +72,10 @@ impl PluginDataService {
search: Option<String>,
sort_by: Option<String>,
sort_order: Option<String>,
cache: &moka::sync::Cache<String, EntityInfo>,
) -> AppResult<(Vec<PluginDataResp>, u64)> {
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let info =
resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).await?;
// 获取 searchable 字段列表
let entity_fields = info.fields()?;
@@ -119,9 +108,9 @@ impl PluginDataService {
.map(|r| r.count as u64)
.unwrap_or(0);
// Query
// Query — 使用 Generated Column 路由
let offset = page.saturating_sub(1) * page_size;
let (sql, values) = DynamicTableManager::build_filtered_query_sql(
let (sql, values) = DynamicTableManager::build_filtered_query_sql_ex(
&info.table_name,
tenant_id,
page_size,
@@ -130,6 +119,7 @@ impl PluginDataService {
search_tuple,
sort_by,
sort_order,
&info.generated_fields,
)
.map_err(|e| AppError::Validation(e))?;
@@ -217,6 +207,14 @@ impl PluginDataService {
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let fields = info.fields()?;
validate_data(&data, &fields)?;
validate_ref_entities(&data, &fields, entity_name, plugin_id, tenant_id, db, false, Some(id)).await?;
// 循环引用检测
for field in &fields {
if field.no_cycle == Some(true) && data.get(&field.name).is_some() {
check_no_cycle(id, field, &data, &info.table_name, tenant_id, db).await?;
}
}
let (sql, values) = DynamicTableManager::build_update_sql(
&info.table_name,
@@ -254,6 +252,45 @@ impl PluginDataService {
})
}
/// 部分更新PATCH— 只合并提供的字段
pub async fn partial_update(
plugin_id: Uuid,
entity_name: &str,
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
partial_data: serde_json::Value,
expected_version: i32,
db: &sea_orm::DatabaseConnection,
) -> AppResult<PluginDataResp> {
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let (sql, values) = DynamicTableManager::build_patch_sql(
&info.table_name, id, tenant_id, operator_id, partial_data, expected_version,
);
#[derive(FromQueryResult)]
struct PatchResult {
id: Uuid,
data: serde_json::Value,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
version: i32,
}
let result = PatchResult::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres, sql, values,
)).one(db).await?.ok_or_else(|| AppError::VersionMismatch)?;
Ok(PluginDataResp {
id: result.id.to_string(),
data: result.data,
created_at: Some(result.created_at),
updated_at: Some(result.updated_at),
version: Some(result.version),
})
}
/// 删除(软删除)
pub async fn delete(
plugin_id: Uuid,
@@ -264,6 +301,65 @@ impl PluginDataService {
_event_bus: &EventBus,
) -> AppResult<()> {
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
// 解析 entity schema 获取 relations
let entity_def: crate::manifest::PluginEntity =
serde_json::from_value(info.schema_json.clone())
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?;
// 处理级联关系
for relation in &entity_def.relations {
let rel_table = DynamicTableManager::table_name(&manifest_id, &relation.entity);
let fk = sanitize_identifier(&relation.foreign_key);
match relation.on_delete {
crate::manifest::OnDeleteStrategy::Restrict => {
let check_sql = format!(
"SELECT 1 as chk FROM \"{}\" WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1",
rel_table, fk
);
#[derive(FromQueryResult)]
struct RefCheck { chk: Option<i32> }
let has_ref = RefCheck::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
check_sql,
[id.to_string().into(), tenant_id.into()],
)).one(db).await?;
if has_ref.is_some() {
return Err(AppError::Validation(format!(
"存在关联的 {} 记录,无法删除",
relation.entity
)));
}
}
crate::manifest::OnDeleteStrategy::Nullify => {
let nullify_sql = format!(
"UPDATE \"{}\" SET data = jsonb_set(data, '{{{}}}', 'null'), updated_at = NOW() WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL",
rel_table, fk, fk
);
db.execute(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
nullify_sql,
[id.to_string().into(), tenant_id.into()],
)).await?;
}
crate::manifest::OnDeleteStrategy::Cascade => {
let cascade_sql = format!(
"UPDATE \"{}\" SET deleted_at = NOW(), updated_at = NOW() WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL",
rel_table, fk
);
db.execute(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
cascade_sql,
[id.to_string().into(), tenant_id.into()],
)).await?;
}
}
}
// 软删除主记录
let (sql, values) = DynamicTableManager::build_delete_sql(&info.table_name, id, tenant_id);
db.execute(Statement::from_sql_and_values(
@@ -276,6 +372,111 @@ impl PluginDataService {
Ok(())
}
/// 批量操作 — batch_delete / batch_update
pub async fn batch(
plugin_id: Uuid,
entity_name: &str,
tenant_id: Uuid,
operator_id: Uuid,
req: BatchActionReq,
db: &sea_orm::DatabaseConnection,
) -> AppResult<u64> {
if req.ids.is_empty() {
return Err(AppError::Validation("ids 不能为空".to_string()));
}
if req.ids.len() > 100 {
return Err(AppError::Validation("批量操作上限 100 条".to_string()));
}
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let ids: Vec<Uuid> = req
.ids
.iter()
.map(|s| Uuid::parse_str(s))
.collect::<Result<Vec<_>, _>>()
.map_err(|_| AppError::Validation("ids 中包含无效的 UUID".to_string()))?;
let affected = match req.action.as_str() {
"batch_delete" => {
let placeholders: Vec<String> = ids
.iter()
.enumerate()
.map(|(i, _)| format!("${}", i + 2))
.collect();
let sql = format!(
"UPDATE \"{}\" SET deleted_at = NOW(), updated_at = NOW() \
WHERE tenant_id = $1 AND id IN ({}) AND deleted_at IS NULL",
info.table_name,
placeholders.join(", ")
);
let mut values = vec![tenant_id.into()];
for id in &ids {
values.push((*id).into());
}
let result = db
.execute(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
values,
))
.await?;
result.rows_affected()
}
"batch_update" => {
let update_data = req.data.ok_or_else(|| {
AppError::Validation("batch_update 需要 data 字段".to_string())
})?;
let mut set_expr = "data".to_string();
if let Some(obj) = update_data.as_object() {
for key in obj.keys() {
let clean_key = sanitize_identifier(key);
set_expr = format!(
"jsonb_set({}, '{{{}}}', $2::jsonb->'{}', true)",
set_expr, clean_key, clean_key
);
}
}
let placeholders: Vec<String> = ids
.iter()
.enumerate()
.map(|(i, _)| format!("${}", i + 3))
.collect();
let sql = format!(
"UPDATE \"{}\" SET data = {}, updated_at = NOW(), updated_by = $1, version = version + 1 \
WHERE tenant_id = $1 AND id IN ({}) AND deleted_at IS NULL",
info.table_name,
set_expr,
placeholders.join(", ")
);
let mut values = vec![operator_id.into()];
values.push(
serde_json::to_string(&update_data)
.unwrap_or_default()
.into(),
);
for id in &ids {
values.push((*id).into());
}
let result = db
.execute(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
values,
))
.await?;
result.rows_affected()
}
_ => {
return Err(AppError::Validation(format!(
"不支持的批量操作: {}",
req.action
)))
}
};
Ok(affected)
}
/// 统计记录数(支持过滤和搜索)
pub async fn count(
plugin_id: Uuid,
@@ -367,6 +568,65 @@ impl PluginDataService {
Ok(result)
}
/// 聚合查询(预留 Redis 缓存接口)
pub async fn aggregate_cached(
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)>> {
// TODO: 未来版本添加 Redis 缓存层
Self::aggregate(plugin_id, entity_name, tenant_id, db, group_by_field, filter).await
}
/// 时间序列聚合 — 按时间字段截断为 day/week/month 统计计数
pub async fn timeseries(
plugin_id: Uuid,
entity_name: &str,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
time_field: &str,
time_grain: &str,
start: Option<String>,
end: Option<String>,
) -> AppResult<Vec<crate::data_dto::TimeseriesItem>> {
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
let (sql, values) = DynamicTableManager::build_timeseries_sql(
&info.table_name,
tenant_id,
time_field,
time_grain,
start.as_deref(),
end.as_deref(),
)
.map_err(|e| AppError::Validation(e))?;
#[derive(FromQueryResult)]
struct TsRow {
period: Option<String>,
count: i64,
}
let rows = TsRow::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
values,
))
.all(db)
.await?;
Ok(rows
.into_iter()
.map(|r| crate::data_dto::TimeseriesItem {
period: r.period.unwrap_or_default(),
count: r.count,
})
.collect())
}
}
/// 从 plugins 表解析 manifest metadata.id如 "erp-crm"
@@ -391,6 +651,7 @@ pub async fn resolve_manifest_id(
}
/// 从 plugin_entities 表获取实体完整信息(带租户隔离)
/// 注意:此函数不填充 generated_fields仅用于非 list 场景
async fn resolve_entity_info(
plugin_id: Uuid,
entity_name: &str,
@@ -411,19 +672,261 @@ async fn resolve_entity_info(
Ok(EntityInfo {
table_name: entity.table_name,
schema_json: entity.schema_json,
generated_fields: vec![], // 旧路径,不追踪 generated_fields
})
}
/// 校验数据:检查 required 字段
/// 从缓存或数据库获取实体信息(带 generated_fields 解析)
pub async fn resolve_entity_info_cached(
plugin_id: Uuid,
entity_name: &str,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
cache: &moka::sync::Cache<String, EntityInfo>,
) -> AppResult<EntityInfo> {
let cache_key = format!("{}:{}:{}", plugin_id, entity_name, tenant_id);
if let Some(info) = cache.get(&cache_key) {
return Ok(info);
}
let entity = plugin_entity::Entity::find()
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
.filter(plugin_entity::Column::EntityName.eq(entity_name))
.filter(plugin_entity::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| {
AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name))
})?;
// 解析 generated_fields
let entity_def: crate::manifest::PluginEntity =
serde_json::from_value(entity.schema_json.clone())
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
let generated_fields: Vec<String> = entity_def
.fields
.iter()
.filter(|f| f.field_type.supports_generated_column())
.filter(|f| {
f.unique
|| f.sortable == Some(true)
|| f.filterable == Some(true)
|| (f.required && (f.sortable == Some(true) || f.filterable == Some(true)))
})
.map(|f| sanitize_identifier(&f.name))
.collect();
let info = EntityInfo {
table_name: entity.table_name,
schema_json: entity.schema_json,
generated_fields,
};
cache.insert(cache_key, info.clone());
Ok(info)
}
/// 校验数据:检查 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 {
let label = field.display_name.as_deref().unwrap_or(&field.name);
// required 检查
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)));
}
// 正则校验
if let Some(validation) = &field.validation {
if let Some(pattern) = &validation.pattern {
if let Some(val) = obj.get(&field.name) {
let str_val = val.as_str().unwrap_or("");
if !str_val.is_empty() {
let re = regex::Regex::new(pattern)
.map_err(|e| AppError::Internal(format!("正则表达式编译失败: {}", e)))?;
if !re.is_match(str_val) {
let default_msg = format!("字段 '{}' 格式不正确", label);
let msg = validation.message.as_deref()
.unwrap_or(&default_msg);
return Err(AppError::Validation(msg.to_string()));
}
}
}
}
}
}
Ok(())
}
/// 校验外键引用 — 检查 ref_entity 字段指向的记录是否存在
async fn validate_ref_entities(
data: &serde_json::Value,
fields: &[PluginField],
current_entity: &str,
plugin_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
is_create: bool,
record_id: Option<Uuid>,
) -> AppResult<()> {
let obj = data.as_object().ok_or_else(|| {
AppError::Validation("data 必须是 JSON 对象".to_string())
})?;
for field in fields {
let Some(ref_entity_name) = &field.ref_entity else { continue };
let Some(val) = obj.get(&field.name) else { continue };
let str_val = val.as_str().unwrap_or("").trim().to_string();
if str_val.is_empty() && !field.required { continue; }
if str_val.is_empty() { continue; }
let ref_id = Uuid::parse_str(&str_val).map_err(|_| {
AppError::Validation(format!(
"字段 '{}' 的值 '{}' 不是有效的 UUID",
field.display_name.as_deref().unwrap_or(&field.name),
str_val
))
})?;
// 自引用 + create跳过记录尚未存在
if ref_entity_name == current_entity && is_create {
continue;
}
// 自引用 + update检查是否引用自身
if ref_entity_name == current_entity && !is_create {
if let Some(rid) = record_id {
if ref_id == rid { continue; }
}
}
// 查询被引用记录是否存在
let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?;
let ref_table = DynamicTableManager::table_name(&manifest_id, ref_entity_name);
let check_sql = format!(
"SELECT 1 as check_result FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1",
ref_table
);
#[derive(FromQueryResult)]
struct ExistsCheck { check_result: Option<i32> }
let result = ExistsCheck::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
check_sql,
[ref_id.into(), tenant_id.into()],
)).one(db).await?;
if result.is_none() {
return Err(AppError::Validation(format!(
"引用的 {} 记录不存在ID: {}",
ref_entity_name, ref_id
)));
}
}
Ok(())
}
/// 循环引用检测 — 用于 no_cycle 字段
async fn check_no_cycle(
record_id: Uuid,
field: &PluginField,
data: &serde_json::Value,
table_name: &str,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AppResult<()> {
let Some(val) = data.get(&field.name) else { return Ok(()) };
let new_parent = val.as_str().unwrap_or("").trim().to_string();
if new_parent.is_empty() { return Ok(()); }
let new_parent_id = Uuid::parse_str(&new_parent).map_err(|_| {
AppError::Validation("parent_id 不是有效的 UUID".to_string())
})?;
let field_name = sanitize_identifier(&field.name);
let mut visited = vec![record_id];
let mut current_id = new_parent_id;
for _ in 0..100 {
if visited.contains(&current_id) {
let label = field.display_name.as_deref().unwrap_or(&field.name);
return Err(AppError::Validation(format!(
"字段 '{}' 形成循环引用", label
)));
}
visited.push(current_id);
let query_sql = format!(
"SELECT data->>'{}' as parent FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
field_name, table_name
);
#[derive(FromQueryResult)]
struct ParentRow { parent: Option<String> }
let row = ParentRow::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
query_sql,
[current_id.into(), tenant_id.into()],
)).one(db).await?;
match row {
Some(r) => {
let parent = r.parent.unwrap_or_default().trim().to_string();
if parent.is_empty() { break; }
current_id = Uuid::parse_str(&parent).map_err(|_| {
AppError::Internal("parent_id 不是有效的 UUID".to_string())
})?;
}
None => break,
}
}
Ok(())
}
#[cfg(test)]
mod validate_tests {
use super::*;
use crate::manifest::{FieldValidation, PluginField, PluginFieldType};
fn make_field(name: &str, pattern: Option<&str>, message: Option<&str>) -> PluginField {
PluginField {
name: name.to_string(),
field_type: PluginFieldType::String,
required: false,
validation: pattern.map(|p| FieldValidation {
pattern: Some(p.to_string()),
message: message.map(|m| m.to_string()),
}),
..PluginField::default_for_field()
}
}
#[test]
fn validate_phone_pattern_rejects_invalid() {
let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), Some("手机号格式不正确"))];
let data = serde_json::json!({"phone": "1234"});
let result = validate_data(&data, &fields);
assert!(result.is_err());
}
#[test]
fn validate_phone_pattern_accepts_valid() {
let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), Some("手机号格式不正确"))];
let data = serde_json::json!({"phone": "13812345678"});
let result = validate_data(&data, &fields);
assert!(result.is_ok());
}
#[test]
fn validate_empty_optional_field_skips_pattern() {
let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), None)];
let data = serde_json::json!({"phone": ""});
let result = validate_data(&data, &fields);
assert!(result.is_ok());
}
}

View File

@@ -266,6 +266,44 @@ impl DynamicTableManager {
(sql, values)
}
/// 构建 PATCH SQL — 只更新 data 中提供的字段,未提供的保持不变
/// 使用 jsonb_set 逐层合并,实现部分更新
pub fn build_patch_sql(
table_name: &str,
id: Uuid,
tenant_id: Uuid,
user_id: Uuid,
partial_data: serde_json::Value,
version: i32,
) -> (String, Vec<Value>) {
let mut set_expr = "data".to_string();
if let Some(obj) = partial_data.as_object() {
for key in obj.keys() {
let clean_key = sanitize_identifier(key);
set_expr = format!(
"jsonb_set({}, '{{{}}}', $1::jsonb->'{}', true)",
set_expr, clean_key, clean_key
);
}
}
let sql = format!(
"UPDATE \"{}\" \
SET data = {}, updated_at = NOW(), updated_by = $2, version = version + 1 \
WHERE id = $3 AND tenant_id = $4 AND version = $5 AND deleted_at IS NULL \
RETURNING id, data, created_at, updated_at, version",
table_name, set_expr
);
let values = vec![
serde_json::to_string(&partial_data).unwrap_or_default().into(),
user_id.into(),
id.into(),
tenant_id.into(),
version.into(),
];
(sql, values)
}
/// 构建 DELETE SQL软删除
pub fn build_delete_sql(
table_name: &str,
@@ -600,6 +638,68 @@ impl DynamicTableManager {
Ok((sql, values))
}
/// 构建数据范围 SQL 条件 — 用于行级数据权限过滤
///
/// 根据权限范围级别 (scope_level) 生成对应的 WHERE 子句和参数:
/// - "all": 无额外条件(空字符串)
/// - "self": 只能看自己创建/拥有的数据
/// - "department" / "department_tree": 能看部门成员的数据
///
/// 返回 (sql_fragment, params)sql_fragment 可直接拼接到 WHERE 子句中
pub fn build_data_scope_condition_with_params(
scope_level: &str,
current_user_id: &Uuid,
owner_field: &str,
dept_member_ids: &[Uuid],
start_param_idx: usize,
generated_fields: &[String],
) -> (String, Vec<Value>) {
let ref_fn = Self::field_reference_fn(generated_fields);
let owner_ref = ref_fn(owner_field);
match scope_level {
"self" => (
format!(
"({} = ${} OR \"created_by\" = ${})",
owner_ref, start_param_idx, start_param_idx
),
vec![
current_user_id.to_string().into(),
(*current_user_id).into(),
],
),
"department" | "department_tree" => {
if dept_member_ids.is_empty() {
// 部门成员为空时退化为 self 范围
(
format!(
"({} = ${} OR \"created_by\" = ${})",
owner_ref, start_param_idx, start_param_idx
),
vec![
current_user_id.to_string().into(),
(*current_user_id).into(),
],
)
} else {
let placeholders: Vec<String> = dept_member_ids
.iter()
.enumerate()
.map(|(i, _)| format!("${}", start_param_idx + i))
.collect();
let values: Vec<Value> = dept_member_ids
.iter()
.map(|id| id.to_string().into())
.collect();
(
format!("{} IN ({})", owner_ref, placeholders.join(", ")),
values,
)
}
}
"all" | _ => (String::new(), vec![]),
}
}
/// 编码游标
pub fn encode_cursor(values: &[String], id: &Uuid) -> String {
let obj = serde_json::json!({
@@ -683,6 +783,61 @@ impl DynamicTableManager {
Ok((sql, values))
}
/// 构建时间序列查询 SQL
pub fn build_timeseries_sql(
table_name: &str,
tenant_id: Uuid,
time_field: &str,
time_grain: &str,
start: Option<&str>,
end: Option<&str>,
) -> Result<(String, Vec<Value>), String> {
let clean_field = sanitize_identifier(time_field);
let grain = match time_grain {
"day" | "week" | "month" => time_grain,
_ => return Err(format!("不支持的 time_grain: {}", time_grain)),
};
let mut conditions = vec![
"\"tenant_id\" = $1".to_string(),
"\"deleted_at\" IS NULL".to_string(),
];
let mut values: Vec<Value> = vec![tenant_id.into()];
let mut param_idx = 2;
if let Some(s) = start {
conditions.push(format!(
"(data->>'{}')::timestamp >= ${}",
clean_field, param_idx
));
values.push(Value::String(Some(Box::new(s.to_string()))));
param_idx += 1;
}
if let Some(e) = end {
conditions.push(format!(
"(data->>'{}')::timestamp < ${}",
clean_field, param_idx
));
values.push(Value::String(Some(Box::new(e.to_string()))));
}
let sql = format!(
"SELECT to_char(date_trunc('{}', (data->>'{}')::timestamp), 'YYYY-MM-DD') as period, \
COUNT(*) as count \
FROM \"{}\" WHERE {} \
GROUP BY date_trunc('{}', (data->>'{}')::timestamp) \
ORDER BY period",
grain,
clean_field,
table_name,
conditions.join(" AND "),
grain,
clean_field,
);
Ok((sql, values))
}
}
#[cfg(test)]
@@ -809,6 +964,25 @@ mod tests {
);
}
// ===== build_patch_sql 测试 =====
#[test]
fn test_build_patch_sql_merges_fields() {
let (sql, values) = DynamicTableManager::build_patch_sql(
"plugin_test_customer",
Uuid::parse_str("00000000-0000-0000-0000-000000000099").unwrap(),
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
Uuid::parse_str("00000000-0000-0000-0000-000000000050").unwrap(),
serde_json::json!({"level": "vip", "status": "active"}),
3,
);
assert!(sql.contains("jsonb_set"), "PATCH 应使用 jsonb_set 合并");
assert!(sql.contains("version = version + 1"), "PATCH 应更新版本号");
assert!(sql.contains("WHERE id = $3"), "应有 id 条件");
assert!(sql.contains("version = $5"), "应有乐观锁");
assert_eq!(values.len(), 5, "应有 5 个参数");
}
// ===== build_filtered_count_sql 测试 =====
#[test]
@@ -956,6 +1130,8 @@ mod tests {
},
],
indexes: vec![],
relations: vec![],
data_scope: None,
};
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
@@ -996,6 +1172,8 @@ mod tests {
..PluginField::default_for_field()
}],
indexes: vec![],
relations: vec![],
data_scope: None,
};
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
@@ -1122,4 +1300,197 @@ mod tests {
"应有 tenant_id + cursor_val + cursor_id + limit"
);
}
// ===== build_data_scope_condition_with_params 测试 =====
#[test]
fn test_build_data_scope_condition_self() {
let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params(
"self",
&Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
"owner_id",
&[],
1,
&[],
);
assert!(
sql.contains("\"data\"->>'owner_id'"),
"self 应包含 owner_id 条件, got: {}",
sql
);
assert!(
sql.contains("\"created_by\""),
"self 应包含 created_by 条件, got: {}",
sql
);
assert_eq!(values.len(), 2);
}
#[test]
fn test_build_data_scope_condition_department() {
let dept_members = vec![
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(),
];
let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params(
"department",
&Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
"owner_id",
&dept_members,
2,
&[],
);
assert!(
sql.contains("IN"),
"department 应使用 IN 条件, got: {}",
sql
);
assert!(
sql.contains("$2"),
"参数索引应从 2 开始, got: {}",
sql
);
assert!(
sql.contains("$3"),
"第二个参数索引应为 3, got: {}",
sql
);
assert_eq!(values.len(), 2);
}
#[test]
fn test_build_data_scope_condition_department_empty_degrades_to_self() {
let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params(
"department",
&Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
"owner_id",
&[],
1,
&[],
);
assert!(
sql.contains("\"created_by\""),
"空部门应退化为 self 范围, got: {}",
sql
);
assert_eq!(values.len(), 2);
}
#[test]
fn test_build_data_scope_condition_all() {
let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params(
"all",
&Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
"owner_id",
&[],
1,
&[],
);
assert!(sql.is_empty(), "all 应返回空条件");
assert!(values.is_empty(), "all 应返回空参数");
}
#[test]
fn test_build_data_scope_condition_department_tree() {
let dept_members = vec![
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(),
Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(),
];
let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params(
"department_tree",
&Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
"owner_id",
&dept_members,
5,
&[],
);
assert!(
sql.contains("IN"),
"department_tree 应使用 IN 条件, got: {}",
sql
);
assert_eq!(values.len(), 3);
}
#[test]
fn test_build_data_scope_condition_with_generated_column() {
let generated = vec!["owner_id".to_string()];
let (sql, _) = DynamicTableManager::build_data_scope_condition_with_params(
"self",
&Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
"owner_id",
&[],
1,
&generated,
);
assert!(
sql.contains("\"_f_owner_id\""),
"generated column 应使用 _f_ 前缀, got: {}",
sql
);
}
// ===== build_timeseries_sql 测试 =====
#[test]
fn test_build_timeseries_sql_day_grain() {
let (sql, values) = DynamicTableManager::build_timeseries_sql(
"plugin_test",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
"occurred_at",
"day",
None,
None,
)
.unwrap();
assert!(sql.contains("date_trunc('day'"), "应有 day 粒度");
assert!(sql.contains("GROUP BY"), "应有 GROUP BY");
assert!(sql.contains("ORDER BY period"), "应按 period 排序");
assert_eq!(values.len(), 1, "仅 tenant_id");
}
#[test]
fn test_build_timeseries_sql_month_grain() {
let (sql, _) = DynamicTableManager::build_timeseries_sql(
"plugin_test",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
"created_date",
"month",
None,
None,
)
.unwrap();
assert!(sql.contains("date_trunc('month'"), "应有 month 粒度");
}
#[test]
fn test_build_timeseries_sql_with_date_range() {
let (sql, values) = DynamicTableManager::build_timeseries_sql(
"plugin_test",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
"occurred_at",
"week",
Some("2026-01-01"),
Some("2026-04-01"),
)
.unwrap();
assert!(sql.contains("date_trunc('week'"), "应有 week 粒度");
assert!(sql.contains(">="), "应有 start 条件");
assert!(sql.contains("<"), "应有 end 条件");
assert_eq!(values.len(), 3, "tenant_id + start + end");
}
#[test]
fn test_build_timeseries_sql_invalid_grain() {
let result = DynamicTableManager::build_timeseries_sql(
"plugin_test",
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
"occurred_at",
"hour",
None,
None,
);
assert!(result.is_err(), "不支持的 grain 应报错");
}
}

View File

@@ -8,12 +8,68 @@ use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::data_dto::{
AggregateItem, AggregateQueryParams, CountQueryParams, CreatePluginDataReq,
PluginDataListParams, PluginDataResp, UpdatePluginDataReq,
AggregateItem, AggregateQueryParams, BatchActionReq, CountQueryParams, CreatePluginDataReq,
PatchPluginDataReq, PluginDataListParams, PluginDataResp, TimeseriesItem, TimeseriesParams,
UpdatePluginDataReq,
};
use crate::data_service::{PluginDataService, resolve_manifest_id};
use crate::state::PluginState;
/// 获取当前用户对指定权限的 data_scope 等级
///
/// 查询 user_roles -> role_permissions -> permissions 链路,
/// 返回匹配权限的 data_scope 设置,默认 "all"。
async fn get_data_scope(
ctx: &TenantContext,
permission_code: &str,
db: &sea_orm::DatabaseConnection,
) -> Result<String, AppError> {
use sea_orm::{FromQueryResult, Statement};
#[derive(FromQueryResult)]
struct ScopeResult {
data_scope: Option<String>,
}
let result = ScopeResult::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
r#"SELECT rp.data_scope
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1 AND ur.tenant_id = $2 AND p.code = $3
LIMIT 1"#,
[
ctx.user_id.into(),
ctx.tenant_id.into(),
permission_code.into(),
],
))
.one(db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(result
.and_then(|r| r.data_scope)
.unwrap_or_else(|| "all".to_string()))
}
/// 获取部门成员 ID 列表
///
/// 当前返回 TenantContext 中的 department_ids。
/// 未来实现递归查询部门树时将支持 include_sub_depts 参数。
async fn get_dept_members(
ctx: &TenantContext,
_include_sub_depts: bool,
) -> Vec<Uuid> {
// 当前 department_ids 为空时返回空列表
// 未来实现递归查询部门树
if ctx.department_ids.is_empty() {
return vec![];
}
ctx.department_ids.clone()
}
/// 计算插件数据操作所需的权限码
/// 格式:{manifest_id}.{entity}.{action},如 erp-crm.customer.list
fn compute_permission_code(manifest_id: &str, entity_name: &str, action: &str) -> String {
@@ -47,9 +103,14 @@ where
{
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")?;
}
require_permission(&ctx, &fine_perm)?;
// TODO(data_scope): 此处注入行级数据权限过滤
// 1. 解析 entity 定义检查 data_scope == Some(true)
// 2. 调用 get_data_scope(&ctx, &fine_perm, &state.db) 获取当前用户的 scope 等级
// 3. 若 scope != "all",调用 get_dept_members 获取部门成员列表
// 4. 将 scope 条件合并到 filter 中传给 PluginDataService::list
// 参考: crates/erp-plugin/src/dynamic_table.rs build_data_scope_condition()
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
@@ -71,6 +132,7 @@ where
params.search,
params.sort_by,
params.sort_order,
&state.entity_cache,
)
.await?;
@@ -106,9 +168,7 @@ where
{
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")?;
}
require_permission(&ctx, &fine_perm)?;
let result = PluginDataService::create(
plugin_id,
@@ -145,9 +205,7 @@ where
{
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")?;
}
require_permission(&ctx, &fine_perm)?;
let result =
PluginDataService::get_by_id(plugin_id, &entity, id, ctx.tenant_id, &state.db).await?;
@@ -178,9 +236,7 @@ where
{
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")?;
}
require_permission(&ctx, &fine_perm)?;
let result = PluginDataService::update(
plugin_id,
@@ -198,6 +254,39 @@ where
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
patch,
path = "/api/v1/plugins/{plugin_id}/{entity}/{id}",
request_body = PatchPluginDataReq,
responses(
(status = 200, description = "部分更新成功", body = ApiResponse<PluginDataResp>),
),
security(("bearer_auth" = [])),
tag = "插件数据"
)]
/// PATCH /api/v1/plugins/{plugin_id}/{entity}/{id} — 部分更新jsonb_set 合并字段)
pub async fn patch_plugin_data<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>,
Json(req): Json<PatchPluginDataReq>,
) -> Result<Json<ApiResponse<PluginDataResp>>, 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, "update");
require_permission(&ctx, &fine_perm)?;
let result = PluginDataService::partial_update(
plugin_id, &entity, id, ctx.tenant_id, ctx.user_id,
req.data, req.version, &state.db,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
delete,
path = "/api/v1/plugins/{plugin_id}/{entity}/{id}",
@@ -219,9 +308,7 @@ where
{
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")?;
}
require_permission(&ctx, &fine_perm)?;
PluginDataService::delete(
plugin_id,
@@ -236,6 +323,49 @@ where
Ok(Json(ApiResponse::ok(())))
}
#[utoipa::path(
post,
path = "/api/v1/plugins/{plugin_id}/{entity}/batch",
request_body = BatchActionReq,
responses(
(status = 200, description = "批量操作成功", body = ApiResponse<u64>),
),
security(("bearer_auth" = [])),
tag = "插件数据"
)]
/// POST /api/v1/plugins/{plugin_id}/{entity}/batch — 批量操作 (batch_delete / batch_update)
pub async fn batch_plugin_data<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path((plugin_id, entity)): Path<(Uuid, String)>,
Json(req): Json<BatchActionReq>,
) -> 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 action_perm = match req.action.as_str() {
"batch_delete" => "delete",
"batch_update" => "update",
_ => "update",
};
let fine_perm = compute_permission_code(&manifest_id, &entity, action_perm);
require_permission(&ctx, &fine_perm)?;
let affected = PluginDataService::batch(
plugin_id,
&entity,
ctx.tenant_id,
ctx.user_id,
req,
&state.db,
)
.await?;
Ok(Json(ApiResponse::ok(affected)))
}
#[utoipa::path(
get,
path = "/api/v1/plugins/{plugin_id}/{entity}/count",
@@ -259,9 +389,7 @@ where
{
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")?;
}
require_permission(&ctx, &fine_perm)?;
// 解析 filter JSON
let filter: Option<serde_json::Value> = params
@@ -305,9 +433,7 @@ where
{
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")?;
}
require_permission(&ctx, &fine_perm)?;
// 解析 filter JSON
let filter: Option<serde_json::Value> = params
@@ -332,3 +458,43 @@ where
Ok(Json(ApiResponse::ok(items)))
}
#[utoipa::path(
get,
path = "/api/v1/plugins/{plugin_id}/{entity}/timeseries",
params(TimeseriesParams),
responses(
(status = 200, description = "时间序列数据", body = ApiResponse<Vec<TimeseriesItem>>),
),
security(("bearer_auth" = [])),
tag = "插件数据"
)]
/// GET /api/v1/plugins/{plugin_id}/{entity}/timeseries — 时间序列聚合
pub async fn get_plugin_timeseries<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path((plugin_id, entity)): Path<(Uuid, String)>,
Query(params): Query<TimeseriesParams>,
) -> Result<Json<ApiResponse<Vec<TimeseriesItem>>>, 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");
require_permission(&ctx, &fine_perm)?;
let result = PluginDataService::timeseries(
plugin_id,
&entity,
ctx.tenant_id,
&state.db,
&params.time_field,
&params.time_grain,
params.start,
params.end,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -43,6 +43,17 @@ pub struct PluginEntity {
pub fields: Vec<PluginField>,
#[serde(default)]
pub indexes: Vec<PluginIndex>,
#[serde(default)]
pub relations: Vec<PluginRelation>,
#[serde(default)]
pub data_scope: Option<bool>, // 是否启用行级数据权限
}
/// 字段校验规则
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldValidation {
pub pattern: Option<String>, // 正则表达式
pub message: Option<String>, // 校验失败提示
}
/// 插件字段定义
@@ -66,6 +77,16 @@ pub struct PluginField {
pub sortable: Option<bool>,
#[serde(default)]
pub visible_when: Option<String>,
pub ref_entity: Option<String>, // 外键引用的实体名
pub ref_label_field: Option<String>, // entity_select 下拉显示的字段名
pub ref_search_fields: Option<Vec<String>>, // entity_select 搜索匹配的字段列表
pub cascade_from: Option<String>, // 级联过滤的来源字段(当前实体)
pub cascade_filter: Option<String>, // 级联过滤的目标字段(引用实体的字段)
pub validation: Option<FieldValidation>, // 字段校验规则
#[serde(default)]
pub no_cycle: Option<bool>, // 禁止循环引用
#[serde(default)]
pub scope_role: Option<String>, // 标记为数据权限的"所有者"字段
}
/// 字段类型
@@ -129,6 +150,14 @@ impl PluginField {
filterable: None,
sortable: None,
visible_when: None,
ref_entity: None,
ref_label_field: None,
ref_search_fields: None,
cascade_from: None,
cascade_filter: None,
validation: None,
no_cycle: None,
scope_role: None,
}
}
}
@@ -142,6 +171,23 @@ pub struct PluginIndex {
pub unique: bool,
}
/// 级联删除策略
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum OnDeleteStrategy {
Nullify, // 置空外键字段
Cascade, // 级联软删除
Restrict, // 存在关联时拒绝删除
}
/// 实体关联关系声明
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginRelation {
pub entity: String,
pub foreign_key: String,
pub on_delete: OnDeleteStrategy,
}
/// 事件订阅配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginEvents {
@@ -210,6 +256,23 @@ pub enum PluginPageType {
#[serde(default)]
icon: Option<String>,
},
#[serde(rename = "kanban")]
Kanban {
entity: String,
label: String,
#[serde(default)]
icon: Option<String>,
lane_field: String,
#[serde(default)]
lane_order: Vec<String>,
card_title_field: String,
#[serde(default)]
card_subtitle_field: Option<String>,
#[serde(default)]
card_fields: Vec<String>,
#[serde(default)]
enable_drag: Option<bool>,
},
}
/// 插件页面区段(用于 detail 页面类型)
@@ -239,6 +302,8 @@ pub struct PluginPermission {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub data_scope_levels: Option<Vec<String>>, // 支持的数据范围等级
}
/// 从 TOML 字符串解析插件清单
@@ -352,6 +417,28 @@ fn validate_pages(pages: &[PluginPageType]) -> PluginResult<()> {
PluginPageType::Dashboard { .. } => {
// dashboard 无需额外验证
}
PluginPageType::Kanban {
entity,
lane_field,
card_title_field,
..
} => {
if entity.is_empty() {
return Err(PluginError::InvalidManifest(
"kanban page 的 entity 不能为空".into(),
));
}
if lane_field.is_empty() {
return Err(PluginError::InvalidManifest(
"kanban page 的 lane_field 不能为空".into(),
));
}
if card_title_field.is_empty() {
return Err(PluginError::InvalidManifest(
"kanban page 的 card_title_field 不能为空".into(),
));
}
}
}
}
Ok(())
@@ -599,4 +686,323 @@ label = "空标签页"
assert_eq!(PluginFieldType::Integer.generated_expr("age"), "(data->>'age')::INTEGER");
assert_eq!(PluginFieldType::Uuid.generated_expr("ref_id"), "(data->>'ref_id')::UUID");
}
#[test]
fn parse_field_with_ref_entity() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "contact"
display_name = "联系人"
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
required = true
display_name = "所属客户"
ref_entity = "customer"
"#;
let manifest = parse_manifest(toml).unwrap();
let field = &manifest.schema.unwrap().entities[0].fields[0];
assert_eq!(field.ref_entity.as_deref(), Some("customer"));
}
#[test]
fn parse_field_with_validation() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "contact"
display_name = "联系人"
[[schema.entities.fields]]
name = "phone"
field_type = "string"
display_name = "手机号"
validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" }
"#;
let manifest = parse_manifest(toml).unwrap();
let field = &manifest.schema.unwrap().entities[0].fields[0];
let v = field.validation.as_ref().unwrap();
assert_eq!(v.pattern.as_deref(), Some("^1[3-9]\\d{9}$"));
assert_eq!(v.message.as_deref(), Some("手机号格式不正确"));
}
#[test]
fn parse_field_with_no_cycle() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "customer"
display_name = "客户"
[[schema.entities.fields]]
name = "parent_id"
field_type = "uuid"
display_name = "上级客户"
ref_entity = "customer"
no_cycle = true
"#;
let manifest = parse_manifest(toml).unwrap();
let field = &manifest.schema.unwrap().entities[0].fields[0];
assert_eq!(field.no_cycle, Some(true));
assert_eq!(field.ref_entity.as_deref(), Some("customer"));
}
#[test]
fn parse_entity_with_relations() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "customer"
display_name = "客户"
[[schema.entities.fields]]
name = "code"
field_type = "string"
required = true
display_name = "编码"
[[schema.entities.relations]]
entity = "contact"
foreign_key = "customer_id"
on_delete = "cascade"
[[schema.entities.relations]]
entity = "customer_tag"
foreign_key = "customer_id"
on_delete = "cascade"
"#;
let manifest = parse_manifest(toml).unwrap();
let entity = &manifest.schema.unwrap().entities[0];
assert_eq!(entity.relations.len(), 2);
assert_eq!(entity.relations[0].entity, "contact");
assert_eq!(entity.relations[0].foreign_key, "customer_id");
assert!(matches!(entity.relations[0].on_delete, OnDeleteStrategy::Cascade));
}
#[test]
fn parse_entity_with_data_scope() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "customer"
display_name = "客户"
data_scope = true
[[schema.entities.fields]]
name = "owner_id"
field_type = "uuid"
display_name = "负责人"
scope_role = "owner"
"#;
let manifest = parse_manifest(toml).unwrap();
let entity = &manifest.schema.unwrap().entities[0];
assert_eq!(entity.data_scope, Some(true));
assert_eq!(entity.fields[0].scope_role.as_deref(), Some("owner"));
}
#[test]
fn parse_permission_with_data_scope_levels() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[[permissions]]
code = "customer.list"
name = "查看客户"
data_scope_levels = ["self", "department", "department_tree", "all"]
"#;
let manifest = parse_manifest(toml).unwrap();
let perm = &manifest.permissions.unwrap()[0];
assert_eq!(perm.data_scope_levels.as_ref().unwrap().len(), 4);
}
#[test]
fn parse_field_with_entity_select() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "contact"
display_name = "联系人"
[[schema.entities.fields]]
name = "customer_id"
field_type = "uuid"
required = true
display_name = "所属客户"
ui_widget = "entity_select"
ref_label_field = "name"
ref_search_fields = ["name", "code"]
"#;
let manifest = parse_manifest(toml).unwrap();
let field = &manifest.schema.unwrap().entities[0].fields[0];
assert_eq!(field.ui_widget.as_deref(), Some("entity_select"));
assert_eq!(field.ref_label_field.as_deref(), Some("name"));
assert_eq!(
field.ref_search_fields.as_deref(),
Some(
&["name".to_string(), "code".to_string()][..]
)
);
}
#[test]
fn parse_field_with_cascade() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[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 = "关联联系人"
ui_widget = "entity_select"
ref_label_field = "name"
ref_search_fields = ["name"]
cascade_from = "customer_id"
cascade_filter = "customer_id"
"#;
let manifest = parse_manifest(toml).unwrap();
let fields = &manifest.schema.unwrap().entities[0].fields;
let contact_field = &fields[1];
assert_eq!(contact_field.ui_widget.as_deref(), Some("entity_select"));
assert_eq!(contact_field.cascade_from.as_deref(), Some("customer_id"));
assert_eq!(contact_field.cascade_filter.as_deref(), Some("customer_id"));
}
#[test]
fn parse_kanban_page() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[ui]
[[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 = ["region", "status"]
enable_drag = true
"#;
let manifest = parse_manifest(toml).unwrap();
let ui = manifest.ui.unwrap();
assert_eq!(ui.pages.len(), 1);
match &ui.pages[0] {
PluginPageType::Kanban {
entity,
label,
icon,
lane_field,
lane_order,
card_title_field,
card_subtitle_field,
card_fields,
enable_drag,
} => {
assert_eq!(entity, "customer");
assert_eq!(label, "销售漏斗");
assert_eq!(icon.as_deref(), Some("swap"));
assert_eq!(lane_field, "level");
assert_eq!(lane_order, &["potential", "normal", "vip", "svip"]);
assert_eq!(card_title_field, "name");
assert_eq!(card_subtitle_field.as_deref(), Some("code"));
assert_eq!(card_fields, &["region", "status"]);
assert_eq!(*enable_drag, Some(true));
}
_ => panic!("Expected Kanban page type"),
}
}
#[test]
fn reject_empty_entity_in_kanban_page() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[ui]
[[ui.pages]]
type = "kanban"
entity = ""
label = "测试"
lane_field = "status"
card_title_field = "name"
"#;
let result = parse_manifest(toml);
assert!(result.is_err());
}
#[test]
fn reject_empty_lane_field_in_kanban_page() {
let toml = r#"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[ui]
[[ui.pages]]
type = "kanban"
entity = "customer"
label = "测试"
lane_field = ""
card_title_field = "name"
"#;
let result = parse_manifest(toml);
assert!(result.is_err());
}
}

View File

@@ -75,6 +75,7 @@ impl PluginModule {
"/plugins/{plugin_id}/{entity}/{id}",
get(crate::handler::data_handler::get_plugin_data::<S>)
.put(crate::handler::data_handler::update_plugin_data::<S>)
.patch(crate::handler::data_handler::patch_plugin_data::<S>)
.delete(crate::handler::data_handler::delete_plugin_data::<S>),
)
// 数据统计路由
@@ -85,6 +86,16 @@ impl PluginModule {
.route(
"/plugins/{plugin_id}/{entity}/aggregate",
get(crate::handler::data_handler::aggregate_plugin_data::<S>),
)
// 批量操作路由
.route(
"/plugins/{plugin_id}/{entity}/batch",
post(crate::handler::data_handler::batch_plugin_data::<S>),
)
// 时间序列路由
.route(
"/plugins/{plugin_id}/{entity}/timeseries",
get(crate::handler::data_handler::get_plugin_timeseries::<S>),
);
admin_routes.merge(data_routes)

View File

@@ -1,5 +1,9 @@
use std::time::Duration;
use moka::sync::Cache;
use sea_orm::DatabaseConnection;
use erp_core::error::{AppError, AppResult};
use erp_core::events::EventBus;
use crate::engine::PluginEngine;
@@ -10,4 +14,39 @@ pub struct PluginState {
pub db: DatabaseConnection,
pub event_bus: EventBus,
pub engine: PluginEngine,
/// Schema 缓存 — key: "{plugin_id}:{entity_name}:{tenant_id}"
pub entity_cache: Cache<String, EntityInfo>,
}
/// 缓存的实体信息
#[derive(Clone, Debug)]
pub struct EntityInfo {
pub table_name: String,
pub schema_json: serde_json::Value,
pub generated_fields: Vec<String>,
}
impl EntityInfo {
/// 从 schema_json 解析字段列表
pub fn fields(&self) -> AppResult<Vec<crate::manifest::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 PluginState {
pub fn new(db: DatabaseConnection, event_bus: EventBus, engine: PluginEngine) -> Self {
let entity_cache = Cache::builder()
.max_capacity(1000)
.time_to_idle(Duration::from_secs(300))
.build();
Self {
db,
event_bus,
engine,
entity_cache,
}
}
}

View File

@@ -30,3 +30,4 @@ erp-plugin.workspace = true
anyhow.workspace = true
uuid.workspace = true
chrono.workspace = true
moka = { version = "0.12", features = ["sync"] }

View File

@@ -35,6 +35,7 @@ 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;
mod m20260418_000036_add_data_scope_to_role_permissions;
pub struct Migrator;
@@ -77,6 +78,7 @@ impl MigratorTrait for Migrator {
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),
Box::new(m20260418_000036_add_data_scope_to_role_permissions::Migration),
]
}
}

View File

@@ -0,0 +1,37 @@
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> {
// 添加 data_scope 列 — 行级数据权限范围
// 可选值: all, self, department, department_tree
manager
.alter_table(
Table::alter()
.table(Alias::new("role_permissions"))
.add_column(
ColumnDef::new(Alias::new("data_scope"))
.string()
.not_null()
.default("all"),
)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Alias::new("role_permissions"))
.drop_column(Alias::new("data_scope"))
.to_owned(),
)
.await
}
}

View File

@@ -371,6 +371,10 @@ async fn main() -> anyhow::Result<()> {
redis: redis_client.clone(),
default_tenant_id,
plugin_engine,
plugin_entity_cache: moka::sync::Cache::builder()
.max_capacity(1000)
.time_to_idle(std::time::Duration::from_secs(300))
.build(),
};
// --- Build the router ---

View File

@@ -18,6 +18,8 @@ pub struct AppState {
pub default_tenant_id: uuid::Uuid,
/// 插件引擎
pub plugin_engine: erp_plugin::engine::PluginEngine,
/// 插件实体缓存
pub plugin_entity_cache: moka::sync::Cache<String, erp_plugin::state::EntityInfo>,
}
/// Allow handlers to extract `DatabaseConnection` directly from `State<AppState>`.
@@ -90,6 +92,7 @@ impl FromRef<AppState> for erp_plugin::state::PluginState {
db: state.db.clone(),
event_bus: state.event_bus.clone(),
engine: state.plugin_engine.clone(),
entity_cache: state.plugin_entity_cache.clone(),
}
}
}