From 92789e67136ec2311531bcd606b0d40bdd4598da Mon Sep 17 00:00:00 2001 From: iven Date: Thu, 16 Apr 2026 12:41:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(crm):=20=E5=88=9B=E5=BB=BA=20CRM=20?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=20crate=20+=20=E5=89=8D=E7=AB=AF=20tabs/tree?= =?UTF-8?q?=20=E9=A1=B5=E9=9D=A2=E7=B1=BB=E5=9E=8B=20+=20=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CRM WASM 插件:Cargo.toml + src/lib.rs + plugin.toml(5 实体 + 9 权限 + 6 页面) - 注册 erp-plugin-crm 到 workspace members - PluginTabsPage: 通用标签页容器,递归渲染子页面 - PluginTreePage: 通用树形页面,前端构建树结构 - App.tsx: 新增 /tabs/:pageLabel 和 /tree/:entityName 路由 - plugin store: 从 manifest pages 生成菜单(支持 tabs 聚合) - MainLayout: 动态图标映射(team/user/message/tags/apartment) --- Cargo.toml | 1 + apps/web/src/App.tsx | 4 + apps/web/src/layouts/MainLayout.tsx | 48 ++- apps/web/src/pages/PluginTabsPage.tsx | 61 ++++ apps/web/src/pages/PluginTreePage.tsx | 142 +++++++++ apps/web/src/stores/plugin.ts | 79 ++++- crates/erp-plugin-crm/Cargo.toml | 13 + crates/erp-plugin-crm/plugin.toml | 409 ++++++++++++++++++++++++++ crates/erp-plugin-crm/src/lib.rs | 29 ++ 9 files changed, 760 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/pages/PluginTabsPage.tsx create mode 100644 apps/web/src/pages/PluginTreePage.tsx create mode 100644 crates/erp-plugin-crm/Cargo.toml create mode 100644 crates/erp-plugin-crm/plugin.toml create mode 100644 crates/erp-plugin-crm/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index e9cbe7b..2c87ad3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/erp-plugin-prototype", "crates/erp-plugin-test-sample", "crates/erp-plugin", + "crates/erp-plugin-crm", ] [workspace.package] diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 52520e0..9509e44 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -16,6 +16,8 @@ const Messages = lazy(() => import('./pages/Messages')); const Settings = lazy(() => import('./pages/Settings')); const PluginAdmin = lazy(() => import('./pages/PluginAdmin')); const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage')); +const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => ({ default: m.PluginTabsPage }))); +const PluginTreePage = lazy(() => import('./pages/PluginTreePage').then((m) => ({ default: m.PluginTreePage }))); function PrivateRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -138,6 +140,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 2382793..141ab3b 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -15,6 +15,11 @@ import { BulbOutlined, BulbFilled, AppstoreOutlined, + TeamOutlined, + TableOutlined, + TagsOutlined, + UserAddOutlined, + ApartmentOutlined as RelationshipIcon, } from '@ant-design/icons'; import { useNavigate, useLocation } from 'react-router-dom'; import { useAppStore } from '../stores/app'; @@ -174,19 +179,36 @@ export default function MainLayout({ children }: { children: React.ReactNode }) <> {!sidebarCollapsed &&
插件
}
- {pluginMenuItems.map((item) => ( - , - label: item.label, - }} - isActive={currentPath === item.key} - collapsed={sidebarCollapsed} - onClick={() => navigate(item.key)} - /> - ))} + {pluginMenuItems.map((item) => { + // 动态图标映射 + const iconMap: Record = { + AppstoreOutlined: , + team: , + TeamOutlined: , + user: , + UserOutlined: , + message: , + MessageOutlined: , + tags: , + TagsOutlined: , + apartment: , + ApartmentOutlined: , + TableOutlined: , + }; + return ( + , + label: item.label, + }} + isActive={currentPath === item.key} + collapsed={sidebarCollapsed} + onClick={() => navigate(item.key)} + /> + ); + })}
)} diff --git a/apps/web/src/pages/PluginTabsPage.tsx b/apps/web/src/pages/PluginTabsPage.tsx new file mode 100644 index 0000000..b07321d --- /dev/null +++ b/apps/web/src/pages/PluginTabsPage.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; +import { Tabs } from 'antd'; +import { + PluginPageSchema, + PluginEntitySchema, + PluginFieldSchema, +} from '../api/plugins'; + +interface PluginTabsPageProps { + pluginId: string; + label: string; + icon?: string; + tabs: PluginPageSchema[]; + entities: PluginEntitySchema[]; +} + +export function PluginTabsPage({ pluginId, label, tabs, entities }: PluginTabsPageProps) { + const [activeKey, setActiveKey] = useState(tabs[0] && 'label' in tabs[0] ? tabs[0].label : ''); + + const renderTabContent = (tab: PluginPageSchema) => { + if (tab.type === 'crud') { + // 懒加载 PluginCRUDPage 避免循环依赖 + const PluginCRUDPage = require('./PluginCRUDPage').default; + return ( + + ); + } + if (tab.type === 'tree') { + const PluginTreePage = require('./PluginTreePage').PluginTreePage; + const entity = entities.find((e) => e.name === tab.entity); + return ( + + ); + } + return
不支持的页面类型: {tab.type}
; + }; + + const items = tabs.map((tab) => ({ + key: 'label' in tab ? tab.label : '', + label: 'label' in tab ? tab.label : '', + children: renderTabContent(tab), + })); + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/pages/PluginTreePage.tsx b/apps/web/src/pages/PluginTreePage.tsx new file mode 100644 index 0000000..35fcc0f --- /dev/null +++ b/apps/web/src/pages/PluginTreePage.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState, useMemo } from 'react'; +import { Tree, Descriptions, Card, Empty, Spin } from 'antd'; +import type { TreeProps } from 'antd'; +import { listPluginData, PluginDataRecord } from '../api/pluginData'; +import { PluginFieldSchema } from '../api/plugins'; + +interface PluginTreePageProps { + pluginId: string; + entity: string; + idField: string; + parentField: string; + labelField: string; + fields: PluginFieldSchema[]; +} + +interface TreeNode { + key: string; + title: string; + children: TreeNode[]; + raw: Record; +} + +export function PluginTreePage({ + pluginId, + entity, + idField, + parentField, + labelField, + fields, +}: PluginTreePageProps) { + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedNode, setSelectedNode] = useState(null); + + useEffect(() => { + async function loadAll() { + setLoading(true); + try { + // 加载全量数据构建树 + let allRecords: PluginDataRecord[] = []; + let page = 1; + let hasMore = true; + while (hasMore) { + const result = await listPluginData(pluginId, entity, page, 100); + allRecords = [...allRecords, ...result.data]; + hasMore = result.data.length === 100 && allRecords.length < result.total; + page++; + } + setRecords(allRecords); + } catch { + // 加载失败 + } + setLoading(false); + } + loadAll(); + }, [pluginId, entity]); + + // 构建树结构 + const treeData = useMemo(() => { + const nodeMap = new Map(); + const rootNodes: TreeNode[] = []; + + // 创建所有节点 + for (const record of records) { + const data = record.data; + const key = String(data[idField] || record.id); + const title = String(data[labelField] || '未命名'); + nodeMap.set(key, { + key, + title, + children: [], + raw: { ...data, _id: record.id, _version: record.version }, + }); + } + + // 构建父子关系 + for (const record of records) { + const data = record.data; + const key = String(data[idField] || record.id); + const parentKey = data[parentField] ? String(data[parentField]) : null; + const node = nodeMap.get(key)!; + + if (parentKey && nodeMap.has(parentKey)) { + nodeMap.get(parentKey)!.children.push(node); + } else { + rootNodes.push(node); + } + } + + return rootNodes; + }, [records, idField, parentField, labelField]); + + const onSelect: TreeProps['onSelect'] = (selectedKeys, info) => { + if (selectedKeys.length > 0) { + setSelectedNode(info.node as unknown as TreeNode); + } else { + setSelectedNode(null); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + {treeData.length === 0 ? ( + + ) : ( + + )} + +
+
+ + {selectedNode ? ( + + {fields.map((field) => ( + + {String(selectedNode.raw[field.name] ?? '-')} + + ))} + + ) : ( + + )} + +
+
+ ); +} diff --git a/apps/web/src/stores/plugin.ts b/apps/web/src/stores/plugin.ts index 4ba5d6a..459170c 100644 --- a/apps/web/src/stores/plugin.ts +++ b/apps/web/src/stores/plugin.ts @@ -1,13 +1,14 @@ import { create } from 'zustand'; -import type { PluginInfo, PluginStatus } from '../api/plugins'; -import { listPlugins } from '../api/plugins'; +import type { PluginInfo, PluginStatus, PluginPageSchema, PluginSchemaResponse } from '../api/plugins'; +import { listPlugins, getPluginSchema } from '../api/plugins'; export interface PluginMenuItem { key: string; icon: string; label: string; pluginId: string; - entity: string; + entity?: string; + pageType: 'crud' | 'tree' | 'tabs' | 'detail'; menuGroup?: string; } @@ -15,6 +16,7 @@ interface PluginStore { plugins: PluginInfo[]; loading: boolean; pluginMenuItems: PluginMenuItem[]; + schemaCache: Record; fetchPlugins: (page?: number, status?: PluginStatus) => Promise; refreshMenuItems: () => void; } @@ -23,12 +25,25 @@ export const usePluginStore = create((set, get) => ({ plugins: [], loading: false, pluginMenuItems: [], + schemaCache: {}, fetchPlugins: async (page = 1, status?: PluginStatus) => { set({ loading: true }); try { const result = await listPlugins(page, 100, status); set({ plugins: result.data }); + + // 预加载所有运行中插件的 schema + const schemas: Record = {}; + for (const plugin of result.data) { + if (plugin.status !== 'running' && plugin.status !== 'enabled') continue; + try { + schemas[plugin.id] = await getPluginSchema(plugin.id) as PluginSchemaResponse; + } catch { + // schema 加载失败跳过 + } + } + set({ schemaCache: schemas }); get().refreshMenuItems(); } finally { set({ loading: false }); @@ -36,21 +51,59 @@ export const usePluginStore = create((set, get) => ({ }, refreshMenuItems: () => { - const { plugins } = get(); + const { plugins, schemaCache } = get(); const items: PluginMenuItem[] = []; for (const plugin of plugins) { if (plugin.status !== 'running' && plugin.status !== 'enabled') continue; - for (const entity of plugin.entities) { - items.push({ - key: `/plugins/${plugin.id}/${entity.name}`, - icon: 'AppstoreOutlined', - label: entity.display_name || entity.name, - pluginId: plugin.id, - entity: entity.name, - menuGroup: undefined, - }); + const schema = schemaCache[plugin.id]; + const pages = (schema as { ui?: { pages: PluginPageSchema[] } })?.ui?.pages; + + if (pages && pages.length > 0) { + for (const page of pages) { + if (page.type === 'tabs') { + // tabs 类型聚合为一个菜单项 + items.push({ + key: `/plugins/${plugin.id}/tabs/${encodeURIComponent('label' in page ? page.label : '')}`, + icon: ('icon' in page ? page.icon : 'AppstoreOutlined') || 'AppstoreOutlined', + label: ('label' in page ? page.label : plugin.name) as string, + pluginId: plugin.id, + pageType: 'tabs', + }); + } else if (page.type === 'tree') { + items.push({ + key: `/plugins/${plugin.id}/tree/${page.entity}`, + icon: ('icon' in page ? page.icon : 'ApartmentOutlined') || 'ApartmentOutlined', + label: ('label' in page ? page.label : page.entity) as string, + pluginId: plugin.id, + entity: page.entity, + pageType: 'tree', + }); + } else if (page.type === 'crud') { + items.push({ + key: `/plugins/${plugin.id}/${page.entity}`, + icon: ('icon' in page ? page.icon : 'TableOutlined') || 'TableOutlined', + label: ('label' in page ? page.label : page.entity) as string, + pluginId: plugin.id, + entity: page.entity, + pageType: 'crud', + }); + } + // detail 类型不生成菜单项 + } + } else { + // 回退:从 entities 生成菜单 + for (const entity of plugin.entities) { + items.push({ + key: `/plugins/${plugin.id}/${entity.name}`, + icon: 'AppstoreOutlined', + label: entity.display_name || entity.name, + pluginId: plugin.id, + entity: entity.name, + pageType: 'crud', + }); + } } } diff --git a/crates/erp-plugin-crm/Cargo.toml b/crates/erp-plugin-crm/Cargo.toml new file mode 100644 index 0000000..0b6d415 --- /dev/null +++ b/crates/erp-plugin-crm/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "erp-plugin-crm" +version = "0.1.0" +edition = "2024" +description = "CRM 客户管理插件 — ERP 平台第一个行业插件" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "0.55" +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/erp-plugin-crm/plugin.toml b/crates/erp-plugin-crm/plugin.toml new file mode 100644 index 0000000..77c44a0 --- /dev/null +++ b/crates/erp-plugin-crm/plugin.toml @@ -0,0 +1,409 @@ +[metadata] +id = "erp-crm" +name = "客户管理" +version = "0.1.0" +description = "客户关系管理插件 — ERP 平台第一个行业插件" +author = "ERP Team" +min_platform_version = "0.1.0" + +# ── 权限声明 ── + +[[permissions]] +code = "customer.list" +name = "查看客户" +description = "查看客户列表和详情" + +[[permissions]] +code = "customer.manage" +name = "管理客户" +description = "创建、编辑、删除客户" + +[[permissions]] +code = "contact.list" +name = "查看联系人" + +[[permissions]] +code = "contact.manage" +name = "管理联系人" + +[[permissions]] +code = "communication.list" +name = "查看沟通记录" + +[[permissions]] +code = "communication.manage" +name = "管理沟通记录" + +[[permissions]] +code = "tag.manage" +name = "管理客户标签" + +[[permissions]] +code = "relationship.list" +name = "查看客户关系" + +[[permissions]] +code = "relationship.manage" +name = "管理客户关系" + +# ── 实体定义 ── + +[[schema.entities]] +name = "customer" +display_name = "客户" + + [[schema.entities.fields]] + name = "code" + field_type = "String" + required = true + display_name = "客户编码" + unique = true + searchable = true + + [[schema.entities.fields]] + name = "name" + field_type = "String" + required = true + display_name = "客户名称" + searchable = true + + [[schema.entities.fields]] + name = "customer_type" + field_type = "String" + required = true + display_name = "客户类型" + ui_widget = "select" + filterable = true + options = [ + { label = "企业", value = "enterprise" }, + { label = "个人", value = "personal" } + ] + + [[schema.entities.fields]] + name = "industry" + field_type = "String" + display_name = "行业" + filterable = true + + [[schema.entities.fields]] + name = "region" + field_type = "String" + display_name = "地区" + filterable = true + + [[schema.entities.fields]] + name = "source" + field_type = "String" + display_name = "来源" + ui_widget = "select" + options = [ + { label = "推荐", value = "referral" }, + { label = "广告", value = "ad" }, + { label = "展会", value = "exhibition" }, + { label = "主动联系", value = "outreach" }, + { label = "其他", value = "other" } + ] + + [[schema.entities.fields]] + name = "level" + field_type = "String" + display_name = "等级" + ui_widget = "select" + filterable = true + options = [ + { label = "潜在客户", value = "potential" }, + { label = "普通客户", value = "normal" }, + { label = "VIP", value = "vip" }, + { label = "SVIP", value = "svip" } + ] + + [[schema.entities.fields]] + name = "status" + field_type = "String" + required = true + display_name = "状态" + ui_widget = "select" + filterable = true + options = [ + { label = "活跃", value = "active" }, + { label = "停用", value = "inactive" }, + { label = "黑名单", value = "blacklist" } + ] + + [[schema.entities.fields]] + name = "credit_code" + field_type = "String" + display_name = "统一社会信用代码" + visible_when = "customer_type == 'enterprise'" + + [[schema.entities.fields]] + name = "id_number" + field_type = "String" + display_name = "身份证号" + visible_when = "customer_type == 'personal'" + + [[schema.entities.fields]] + name = "parent_id" + field_type = "Uuid" + display_name = "上级客户" + + [[schema.entities.fields]] + name = "website" + field_type = "String" + display_name = "网站" + + [[schema.entities.fields]] + name = "address" + field_type = "String" + display_name = "地址" + + [[schema.entities.fields]] + name = "remark" + field_type = "String" + display_name = "备注" + ui_widget = "textarea" + +[[schema.entities]] +name = "contact" +display_name = "联系人" + + [[schema.entities.fields]] + name = "customer_id" + field_type = "Uuid" + required = true + display_name = "所属客户" + + [[schema.entities.fields]] + name = "name" + field_type = "String" + required = true + display_name = "姓名" + searchable = true + + [[schema.entities.fields]] + name = "position" + field_type = "String" + display_name = "职务" + + [[schema.entities.fields]] + name = "department" + field_type = "String" + display_name = "部门" + + [[schema.entities.fields]] + name = "phone" + field_type = "String" + display_name = "手机号" + + [[schema.entities.fields]] + name = "email" + field_type = "String" + display_name = "邮箱" + + [[schema.entities.fields]] + name = "wechat" + field_type = "String" + display_name = "微信号" + + [[schema.entities.fields]] + name = "is_primary" + field_type = "Boolean" + display_name = "主联系人" + + [[schema.entities.fields]] + name = "remark" + field_type = "String" + display_name = "备注" + +[[schema.entities]] +name = "communication" +display_name = "沟通记录" + + [[schema.entities.fields]] + name = "customer_id" + field_type = "Uuid" + required = true + display_name = "关联客户" + + [[schema.entities.fields]] + name = "contact_id" + field_type = "Uuid" + display_name = "关联联系人" + + [[schema.entities.fields]] + name = "type" + field_type = "String" + required = true + display_name = "类型" + ui_widget = "select" + filterable = true + options = [ + { label = "电话", value = "phone" }, + { label = "邮件", value = "email" }, + { label = "会议", value = "meeting" }, + { label = "拜访", value = "visit" }, + { label = "其他", value = "other" } + ] + + [[schema.entities.fields]] + name = "subject" + field_type = "String" + required = true + display_name = "主题" + searchable = true + + [[schema.entities.fields]] + name = "content" + field_type = "String" + required = true + display_name = "内容" + ui_widget = "textarea" + + [[schema.entities.fields]] + name = "occurred_at" + field_type = "DateTime" + required = true + display_name = "沟通时间" + sortable = true + + [[schema.entities.fields]] + name = "next_follow_up" + field_type = "Date" + display_name = "下次跟进日期" + +[[schema.entities]] +name = "customer_tag" +display_name = "客户标签" + + [[schema.entities.fields]] + name = "customer_id" + field_type = "Uuid" + required = true + display_name = "关联客户" + + [[schema.entities.fields]] + name = "tag_name" + field_type = "String" + required = true + display_name = "标签名称" + searchable = true + + [[schema.entities.fields]] + name = "tag_category" + field_type = "String" + display_name = "标签分类" + ui_widget = "select" + options = [ + { label = "行业", value = "industry" }, + { label = "地区", value = "region" }, + { label = "来源", value = "source" }, + { label = "自定义", value = "custom" } + ] + +[[schema.entities]] +name = "customer_relationship" +display_name = "客户关系" + + [[schema.entities.fields]] + name = "from_customer_id" + field_type = "Uuid" + required = true + display_name = "源客户" + + [[schema.entities.fields]] + name = "to_customer_id" + field_type = "Uuid" + required = true + display_name = "目标客户" + + [[schema.entities.fields]] + name = "relationship_type" + field_type = "String" + required = true + display_name = "关系类型" + ui_widget = "select" + filterable = true + options = [ + { label = "母子公司", value = "parent_child" }, + { label = "兄弟公司", value = "sibling" }, + { label = "合作伙伴", value = "partner" }, + { label = "供应商", value = "supplier" }, + { label = "竞争对手", value = "competitor" } + ] + + [[schema.entities.fields]] + name = "description" + field_type = "String" + display_name = "关系描述" + +# ── 页面声明 ── + +[[ui.pages]] +type = "tabs" +label = "客户管理" +icon = "team" + + [[ui.pages.tabs]] + label = "客户列表" + type = "crud" + entity = "customer" + enable_search = true + enable_views = ["table"] + + [[ui.pages.tabs]] + label = "客户层级" + type = "tree" + entity = "customer" + id_field = "id" + parent_field = "parent_id" + label_field = "name" + +[[ui.pages]] +type = "detail" +entity = "customer" +label = "客户详情" + + [[ui.pages.sections]] + type = "fields" + label = "基本信息" + fields = ["code", "name", "customer_type", "industry", "region", "level", "status", "credit_code", "id_number", "website", "address", "remark"] + + [[ui.pages.sections]] + type = "crud" + label = "联系人" + entity = "contact" + filter_field = "customer_id" + + [[ui.pages.sections]] + type = "crud" + label = "沟通记录" + entity = "communication" + filter_field = "customer_id" + enable_views = ["table", "timeline"] + +[[ui.pages]] +type = "crud" +entity = "contact" +label = "联系人" +icon = "user" +enable_search = true + +[[ui.pages]] +type = "crud" +entity = "communication" +label = "沟通记录" +icon = "message" +enable_search = true +enable_views = ["table", "timeline"] + +[[ui.pages]] +type = "crud" +entity = "customer_tag" +label = "标签管理" +icon = "tags" + +[[ui.pages]] +type = "crud" +entity = "customer_relationship" +label = "客户关系" +icon = "apartment" diff --git a/crates/erp-plugin-crm/src/lib.rs b/crates/erp-plugin-crm/src/lib.rs new file mode 100644 index 0000000..b08ee39 --- /dev/null +++ b/crates/erp-plugin-crm/src/lib.rs @@ -0,0 +1,29 @@ +//! CRM 客户管理插件 — WASM Guest 实现 + +wit_bindgen::generate!({ + path: "../erp-plugin-prototype/wit/plugin.wit", + world: "plugin-world", +}); + +use crate::exports::erp::plugin::plugin_api::Guest; + +struct CrmPlugin; + +impl Guest for CrmPlugin { + fn init() -> Result<(), String> { + // CRM 插件初始化:当前无需创建默认数据 + Ok(()) + } + + fn on_tenant_created(_tenant_id: String) -> Result<(), String> { + // 为新租户创建 CRM 默认数据:当前无需创建默认客户 + Ok(()) + } + + fn handle_event(_event_type: String, _payload: Vec) -> Result<(), String> { + // CRM V1: 无事件处理 + Ok(()) + } +} + +export!(CrmPlugin);