feat(crm): 创建 CRM 插件 crate + 前端 tabs/tree 页面类型 + 动态菜单
- 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)
This commit is contained in:
@@ -11,6 +11,7 @@ members = [
|
|||||||
"crates/erp-plugin-prototype",
|
"crates/erp-plugin-prototype",
|
||||||
"crates/erp-plugin-test-sample",
|
"crates/erp-plugin-test-sample",
|
||||||
"crates/erp-plugin",
|
"crates/erp-plugin",
|
||||||
|
"crates/erp-plugin-crm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const Messages = lazy(() => import('./pages/Messages'));
|
|||||||
const Settings = lazy(() => import('./pages/Settings'));
|
const Settings = lazy(() => import('./pages/Settings'));
|
||||||
const PluginAdmin = lazy(() => import('./pages/PluginAdmin'));
|
const PluginAdmin = lazy(() => import('./pages/PluginAdmin'));
|
||||||
const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage'));
|
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 }) {
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
@@ -138,6 +140,8 @@ export default function App() {
|
|||||||
<Route path="/messages" element={<Messages />} />
|
<Route path="/messages" element={<Messages />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/plugins/admin" element={<PluginAdmin />} />
|
<Route path="/plugins/admin" element={<PluginAdmin />} />
|
||||||
|
<Route path="/plugins/:pluginId/tabs/:pageLabel" element={<PluginTabsPage />} />
|
||||||
|
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
|
||||||
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
|
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import {
|
|||||||
BulbOutlined,
|
BulbOutlined,
|
||||||
BulbFilled,
|
BulbFilled,
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
TableOutlined,
|
||||||
|
TagsOutlined,
|
||||||
|
UserAddOutlined,
|
||||||
|
ApartmentOutlined as RelationshipIcon,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAppStore } from '../stores/app';
|
import { useAppStore } from '../stores/app';
|
||||||
@@ -174,19 +179,36 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
|||||||
<>
|
<>
|
||||||
{!sidebarCollapsed && <div className="erp-sidebar-group">插件</div>}
|
{!sidebarCollapsed && <div className="erp-sidebar-group">插件</div>}
|
||||||
<div className="erp-sidebar-menu">
|
<div className="erp-sidebar-menu">
|
||||||
{pluginMenuItems.map((item) => (
|
{pluginMenuItems.map((item) => {
|
||||||
|
// 动态图标映射
|
||||||
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
|
AppstoreOutlined: <AppstoreOutlined />,
|
||||||
|
team: <TeamOutlined />,
|
||||||
|
TeamOutlined: <TeamOutlined />,
|
||||||
|
user: <UserOutlined />,
|
||||||
|
UserOutlined: <UserOutlined />,
|
||||||
|
message: <MessageOutlined />,
|
||||||
|
MessageOutlined: <MessageOutlined />,
|
||||||
|
tags: <TagsOutlined />,
|
||||||
|
TagsOutlined: <TagsOutlined />,
|
||||||
|
apartment: <ApartmentOutlined />,
|
||||||
|
ApartmentOutlined: <ApartmentOutlined />,
|
||||||
|
TableOutlined: <TableOutlined />,
|
||||||
|
};
|
||||||
|
return (
|
||||||
<SidebarMenuItem
|
<SidebarMenuItem
|
||||||
key={item.key}
|
key={item.key}
|
||||||
item={{
|
item={{
|
||||||
key: item.key,
|
key: item.key,
|
||||||
icon: <AppstoreOutlined />,
|
icon: iconMap[item.icon] || <AppstoreOutlined />,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
}}
|
}}
|
||||||
isActive={currentPath === item.key}
|
isActive={currentPath === item.key}
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
onClick={() => navigate(item.key)}
|
onClick={() => navigate(item.key)}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
61
apps/web/src/pages/PluginTabsPage.tsx
Normal file
61
apps/web/src/pages/PluginTabsPage.tsx
Normal file
@@ -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 (
|
||||||
|
<PluginCRUDPage
|
||||||
|
pluginIdOverride={pluginId}
|
||||||
|
entityOverride={tab.entity}
|
||||||
|
enableSearch={tab.enable_search}
|
||||||
|
enableViews={tab.enable_views}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tab.type === 'tree') {
|
||||||
|
const PluginTreePage = require('./PluginTreePage').PluginTreePage;
|
||||||
|
const entity = entities.find((e) => e.name === tab.entity);
|
||||||
|
return (
|
||||||
|
<PluginTreePage
|
||||||
|
pluginId={pluginId}
|
||||||
|
entity={tab.entity}
|
||||||
|
idField={tab.id_field}
|
||||||
|
parentField={tab.parent_field}
|
||||||
|
labelField={tab.label_field}
|
||||||
|
fields={entity?.fields || []}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div>不支持的页面类型: {tab.type}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = tabs.map((tab) => ({
|
||||||
|
key: 'label' in tab ? tab.label : '',
|
||||||
|
label: 'label' in tab ? tab.label : '',
|
||||||
|
children: renderTabContent(tab),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Tabs activeKey={activeKey} onChange={setActiveKey} items={items} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
apps/web/src/pages/PluginTreePage.tsx
Normal file
142
apps/web/src/pages/PluginTreePage.tsx
Normal file
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginTreePage({
|
||||||
|
pluginId,
|
||||||
|
entity,
|
||||||
|
idField,
|
||||||
|
parentField,
|
||||||
|
labelField,
|
||||||
|
fields,
|
||||||
|
}: PluginTreePageProps) {
|
||||||
|
const [records, setRecords] = useState<PluginDataRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedNode, setSelectedNode] = useState<TreeNode | null>(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<string, TreeNode>();
|
||||||
|
const rootNodes: TreeNode[] = [];
|
||||||
|
|
||||||
|
// 创建所有节点
|
||||||
|
for (const record of records) {
|
||||||
|
const data = record.data;
|
||||||
|
const key = String(data[idField] || record.id);
|
||||||
|
const title = String(data[labelField] || '未命名');
|
||||||
|
nodeMap.set(key, {
|
||||||
|
key,
|
||||||
|
title,
|
||||||
|
children: [],
|
||||||
|
raw: { ...data, _id: record.id, _version: record.version },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建父子关系
|
||||||
|
for (const record of records) {
|
||||||
|
const data = record.data;
|
||||||
|
const key = String(data[idField] || record.id);
|
||||||
|
const parentKey = data[parentField] ? String(data[parentField]) : null;
|
||||||
|
const node = nodeMap.get(key)!;
|
||||||
|
|
||||||
|
if (parentKey && nodeMap.has(parentKey)) {
|
||||||
|
nodeMap.get(parentKey)!.children.push(node);
|
||||||
|
} else {
|
||||||
|
rootNodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootNodes;
|
||||||
|
}, [records, idField, parentField, labelField]);
|
||||||
|
|
||||||
|
const onSelect: TreeProps['onSelect'] = (selectedKeys, info) => {
|
||||||
|
if (selectedKeys.length > 0) {
|
||||||
|
setSelectedNode(info.node as unknown as TreeNode);
|
||||||
|
} else {
|
||||||
|
setSelectedNode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, display: 'flex', gap: 16 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Card title={entity + ' 层级'} size="small">
|
||||||
|
{treeData.length === 0 ? (
|
||||||
|
<Empty description="暂无数据" />
|
||||||
|
) : (
|
||||||
|
<Tree
|
||||||
|
showLine
|
||||||
|
defaultExpandAll
|
||||||
|
treeData={treeData}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Card title="节点详情" size="small">
|
||||||
|
{selectedNode ? (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<Descriptions.Item key={field.name} label={field.display_name || field.name}>
|
||||||
|
{String(selectedNode.raw[field.name] ?? '-')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
))}
|
||||||
|
</Descriptions>
|
||||||
|
) : (
|
||||||
|
<Empty description="点击左侧节点查看详情" />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { PluginInfo, PluginStatus } from '../api/plugins';
|
import type { PluginInfo, PluginStatus, PluginPageSchema, PluginSchemaResponse } from '../api/plugins';
|
||||||
import { listPlugins } from '../api/plugins';
|
import { listPlugins, getPluginSchema } from '../api/plugins';
|
||||||
|
|
||||||
export interface PluginMenuItem {
|
export interface PluginMenuItem {
|
||||||
key: string;
|
key: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
label: string;
|
label: string;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
entity: string;
|
entity?: string;
|
||||||
|
pageType: 'crud' | 'tree' | 'tabs' | 'detail';
|
||||||
menuGroup?: string;
|
menuGroup?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ interface PluginStore {
|
|||||||
plugins: PluginInfo[];
|
plugins: PluginInfo[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
pluginMenuItems: PluginMenuItem[];
|
pluginMenuItems: PluginMenuItem[];
|
||||||
|
schemaCache: Record<string, PluginSchemaResponse>;
|
||||||
fetchPlugins: (page?: number, status?: PluginStatus) => Promise<void>;
|
fetchPlugins: (page?: number, status?: PluginStatus) => Promise<void>;
|
||||||
refreshMenuItems: () => void;
|
refreshMenuItems: () => void;
|
||||||
}
|
}
|
||||||
@@ -23,12 +25,25 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
|
|||||||
plugins: [],
|
plugins: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
pluginMenuItems: [],
|
pluginMenuItems: [],
|
||||||
|
schemaCache: {},
|
||||||
|
|
||||||
fetchPlugins: async (page = 1, status?: PluginStatus) => {
|
fetchPlugins: async (page = 1, status?: PluginStatus) => {
|
||||||
set({ loading: true });
|
set({ loading: true });
|
||||||
try {
|
try {
|
||||||
const result = await listPlugins(page, 100, status);
|
const result = await listPlugins(page, 100, status);
|
||||||
set({ plugins: result.data });
|
set({ plugins: result.data });
|
||||||
|
|
||||||
|
// 预加载所有运行中插件的 schema
|
||||||
|
const schemas: Record<string, PluginSchemaResponse> = {};
|
||||||
|
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();
|
get().refreshMenuItems();
|
||||||
} finally {
|
} finally {
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
@@ -36,12 +51,49 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
refreshMenuItems: () => {
|
refreshMenuItems: () => {
|
||||||
const { plugins } = get();
|
const { plugins, schemaCache } = get();
|
||||||
const items: PluginMenuItem[] = [];
|
const items: PluginMenuItem[] = [];
|
||||||
|
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
if (plugin.status !== 'running' && plugin.status !== 'enabled') continue;
|
if (plugin.status !== 'running' && plugin.status !== 'enabled') continue;
|
||||||
|
|
||||||
|
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) {
|
for (const entity of plugin.entities) {
|
||||||
items.push({
|
items.push({
|
||||||
key: `/plugins/${plugin.id}/${entity.name}`,
|
key: `/plugins/${plugin.id}/${entity.name}`,
|
||||||
@@ -49,10 +101,11 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
|
|||||||
label: entity.display_name || entity.name,
|
label: entity.display_name || entity.name,
|
||||||
pluginId: plugin.id,
|
pluginId: plugin.id,
|
||||||
entity: entity.name,
|
entity: entity.name,
|
||||||
menuGroup: undefined,
|
pageType: 'crud',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
set({ pluginMenuItems: items });
|
set({ pluginMenuItems: items });
|
||||||
},
|
},
|
||||||
|
|||||||
13
crates/erp-plugin-crm/Cargo.toml
Normal file
13
crates/erp-plugin-crm/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "erp-plugin-crm"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "CRM 客户管理插件 — ERP 平台第一个行业插件"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wit-bindgen = "0.55"
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
409
crates/erp-plugin-crm/plugin.toml
Normal file
409
crates/erp-plugin-crm/plugin.toml
Normal file
@@ -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"
|
||||||
29
crates/erp-plugin-crm/src/lib.rs
Normal file
29
crates/erp-plugin-crm/src/lib.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
//! CRM 客户管理插件 — WASM Guest 实现
|
||||||
|
|
||||||
|
wit_bindgen::generate!({
|
||||||
|
path: "../erp-plugin-prototype/wit/plugin.wit",
|
||||||
|
world: "plugin-world",
|
||||||
|
});
|
||||||
|
|
||||||
|
use crate::exports::erp::plugin::plugin_api::Guest;
|
||||||
|
|
||||||
|
struct CrmPlugin;
|
||||||
|
|
||||||
|
impl Guest for CrmPlugin {
|
||||||
|
fn init() -> Result<(), String> {
|
||||||
|
// CRM 插件初始化:当前无需创建默认数据
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_tenant_created(_tenant_id: String) -> Result<(), String> {
|
||||||
|
// 为新租户创建 CRM 默认数据:当前无需创建默认客户
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> {
|
||||||
|
// CRM V1: 无事件处理
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export!(CrmPlugin);
|
||||||
Reference in New Issue
Block a user