feat(crm): 新增关系图谱和统计概览页面 + UI/UX 全面优化
后端: - manifest.rs 新增 Graph 和 Dashboard 页面类型到 PluginPageType 枚举 - 添加 graph 页面验证逻辑(entity/relationship_entity/source_field/target_field) CRM 插件: - plugin.toml 新增关系图谱页面(graph 类型,基于 customer_relationship 实体) - plugin.toml 新增统计概览页面(dashboard 类型) - 侧边栏菜单从 5 项扩展到 7 项 前端 — 关系图谱 (PluginGraphPage): - 渐变节点 + 曲线箭头连线 + 关系类型色彩区分 - 鼠标悬停高亮 + Canvas Tooltip + 点击设为中心节点 - 2-hop 邻居视图 + 统计卡片(客户总数/关系总数/当前中心) - 关系类型图例(可点击筛选)+ 暗色主题适配 - ResizeObserver 自适应 + requestAnimationFrame 动画循环 前端 — 统计概览 (PluginDashboardPage): - 5 实体统计卡片(渐变色条 + 图标 + 数字动画) - 可筛选字段分布卡片(Progress 进度条 + Tag 标签) - 响应式栅格布局 + 骨架屏加载态 + 错误状态持久展示
This commit is contained in:
@@ -1,33 +1,367 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Card, Row, Col, Statistic, Spin, Empty, Select, Tag, message } from 'antd';
|
import { Row, Col, Spin, Empty, Select, Tag, Progress, Skeleton, theme, Tooltip } from 'antd';
|
||||||
import {
|
import {
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
RiseOutlined,
|
|
||||||
PhoneOutlined,
|
PhoneOutlined,
|
||||||
TagsOutlined,
|
TagsOutlined,
|
||||||
|
RiseOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { countPluginData, aggregatePluginData, type AggregateItem } from '../api/pluginData';
|
import { countPluginData, aggregatePluginData, type AggregateItem } from '../api/pluginData';
|
||||||
import { getPluginSchema, type PluginEntitySchema, type PluginSchemaResponse } from '../api/plugins';
|
import { getPluginSchema, type PluginEntitySchema, type PluginSchemaResponse } from '../api/plugins';
|
||||||
|
|
||||||
/**
|
// ── 类型定义 ──
|
||||||
* 插件统计概览页面 — 通过路由参数自加载 schema,使用后端 aggregate API
|
|
||||||
* 路由: /plugins/:pluginId/dashboard
|
interface EntityStat {
|
||||||
*/
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
count: number;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
gradient: string;
|
||||||
|
iconBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldBreakdown {
|
||||||
|
fieldName: string;
|
||||||
|
displayName: string;
|
||||||
|
items: AggregateItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 色板配置 ──
|
||||||
|
|
||||||
|
const ENTITY_PALETTE: Record<string, { gradient: string; iconBg: string; tagColor: string }> = {
|
||||||
|
customer: {
|
||||||
|
gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)',
|
||||||
|
iconBg: 'rgba(79, 70, 229, 0.12)',
|
||||||
|
tagColor: 'purple',
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
gradient: 'linear-gradient(135deg, #059669, #10B981)',
|
||||||
|
iconBg: 'rgba(5, 150, 105, 0.12)',
|
||||||
|
tagColor: 'green',
|
||||||
|
},
|
||||||
|
communication: {
|
||||||
|
gradient: 'linear-gradient(135deg, #D97706, #F59E0B)',
|
||||||
|
iconBg: 'rgba(217, 119, 6, 0.12)',
|
||||||
|
tagColor: 'orange',
|
||||||
|
},
|
||||||
|
customer_tag: {
|
||||||
|
gradient: 'linear-gradient(135deg, #7C3AED, #A78BFA)',
|
||||||
|
iconBg: 'rgba(124, 58, 237, 0.12)',
|
||||||
|
tagColor: 'volcano',
|
||||||
|
},
|
||||||
|
customer_relationship: {
|
||||||
|
gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)',
|
||||||
|
iconBg: 'rgba(225, 29, 72, 0.12)',
|
||||||
|
tagColor: 'red',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PALETTE = {
|
||||||
|
gradient: 'linear-gradient(135deg, #2563EB, #3B82F6)',
|
||||||
|
iconBg: 'rgba(37, 99, 235, 0.12)',
|
||||||
|
tagColor: 'blue',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TAG_COLORS = [
|
||||||
|
'blue', 'green', 'orange', 'red', 'purple', 'cyan',
|
||||||
|
'magenta', 'gold', 'lime', 'geekblue', 'volcano',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── 图标映射 ──
|
||||||
|
|
||||||
|
const ENTITY_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
customer: <TeamOutlined />,
|
||||||
|
contact: <TeamOutlined />,
|
||||||
|
communication: <PhoneOutlined />,
|
||||||
|
customer_tag: <TagsOutlined />,
|
||||||
|
customer_relationship: <RiseOutlined />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 计数动画 Hook ──
|
||||||
|
|
||||||
|
function useCountUp(end: number, duration = 800) {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const prevEnd = useRef(end);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (end === prevEnd.current && count > 0) return;
|
||||||
|
prevEnd.current = end;
|
||||||
|
|
||||||
|
if (end === 0) {
|
||||||
|
setCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
function tick(now: number) {
|
||||||
|
const elapsed = now - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
setCount(Math.round(end * eased));
|
||||||
|
if (progress < 1) requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}, [end, duration]);
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 子组件 ──
|
||||||
|
|
||||||
|
function StatValue({ value, loading }: { value: number; loading: boolean }) {
|
||||||
|
const animatedValue = useCountUp(value);
|
||||||
|
if (loading) return <Spin size="small" />;
|
||||||
|
return <span className="erp-count-up">{animatedValue.toLocaleString()}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部统计卡片 */
|
||||||
|
function StatCard({
|
||||||
|
stat,
|
||||||
|
loading,
|
||||||
|
delay,
|
||||||
|
}: {
|
||||||
|
stat: EntityStat;
|
||||||
|
loading: boolean;
|
||||||
|
delay: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Col xs={12} sm={8} lg={Math.max(4, 24 / 5)} key={stat.name}>
|
||||||
|
<div
|
||||||
|
className={`erp-stat-card ${delay}`}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--card-gradient': stat.gradient,
|
||||||
|
'--card-icon-bg': stat.iconBg,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="erp-stat-card-bar" />
|
||||||
|
<div className="erp-stat-card-body">
|
||||||
|
<div className="erp-stat-card-info">
|
||||||
|
<div className="erp-stat-card-title">{stat.displayName}</div>
|
||||||
|
<div className="erp-stat-card-value">
|
||||||
|
<StatValue value={stat.count} loading={loading} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="erp-stat-card-icon">{stat.icon}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 骨架屏卡片 */
|
||||||
|
function SkeletonStatCard({ delay }: { delay: string }) {
|
||||||
|
return (
|
||||||
|
<Col xs={12} sm={8} lg={Math.max(4, 24 / 5)}>
|
||||||
|
<div className={`erp-stat-card ${delay}`}>
|
||||||
|
<div className="erp-stat-card-bar" style={{ opacity: 0.3 }} />
|
||||||
|
<div className="erp-stat-card-body">
|
||||||
|
<div className="erp-stat-card-info">
|
||||||
|
<div style={{ width: 80, height: 14, marginBottom: 12 }}>
|
||||||
|
<Skeleton.Input active size="small" style={{ width: 80, height: 14 }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 60, height: 32 }}>
|
||||||
|
<Skeleton.Input active style={{ width: 60, height: 32 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 48, height: 48 }}>
|
||||||
|
<Skeleton.Avatar active shape="square" size={48} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字段分布卡片 */
|
||||||
|
function BreakdownCard({
|
||||||
|
breakdown,
|
||||||
|
totalCount,
|
||||||
|
palette,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
breakdown: FieldBreakdown;
|
||||||
|
totalCount: number;
|
||||||
|
palette: { tagColor: string };
|
||||||
|
index: number;
|
||||||
|
}) {
|
||||||
|
const maxCount = Math.max(...breakdown.items.map((i) => i.count), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col xs={24} sm={12} lg={8} key={breakdown.fieldName}>
|
||||||
|
<div
|
||||||
|
className={`erp-content-card erp-fade-in`}
|
||||||
|
style={{ animationDelay: `${0.05 * index}s`, opacity: 0 }}
|
||||||
|
>
|
||||||
|
<div className="erp-section-header" style={{ marginBottom: 16 }}>
|
||||||
|
<InfoCircleOutlined className="erp-section-icon" style={{ fontSize: 14 }} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--erp-text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{breakdown.displayName}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--erp-text-tertiary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{breakdown.items.length} 项
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{breakdown.items.map((item, idx) => {
|
||||||
|
const percent = totalCount > 0 ? Math.round((item.count / totalCount) * 100) : 0;
|
||||||
|
const barPercent = maxCount > 0 ? Math.round((item.count / maxCount) * 100) : 0;
|
||||||
|
const color = TAG_COLORS[idx % TAG_COLORS.length];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${breakdown.fieldName}-${item.key}-${idx}`}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title={`${item.key}: ${item.count} (${percent}%)`}>
|
||||||
|
<Tag
|
||||||
|
color={color}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
maxWidth: '60%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.key || '(空)'}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--erp-text-primary)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.count}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 400,
|
||||||
|
color: 'var(--erp-text-tertiary)',
|
||||||
|
marginLeft: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{percent}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={barPercent}
|
||||||
|
showInfo={false}
|
||||||
|
strokeColor={color === 'blue' ? '#3B82F6'
|
||||||
|
: color === 'green' ? '#10B981'
|
||||||
|
: color === 'orange' ? '#F59E0B'
|
||||||
|
: color === 'red' ? '#EF4444'
|
||||||
|
: color === 'purple' ? '#8B5CF6'
|
||||||
|
: color === 'cyan' ? '#06B6D4'
|
||||||
|
: color === 'magenta' ? '#EC4899'
|
||||||
|
: color === 'gold' ? '#EAB308'
|
||||||
|
: color === 'lime' ? '#84CC16'
|
||||||
|
: color === 'geekblue' ? '#6366F1'
|
||||||
|
: color === 'volcano' ? '#F97316'
|
||||||
|
: '#3B82F6'}
|
||||||
|
trailColor="var(--erp-border-light)"
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{breakdown.items.length === 0 && (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="暂无数据"
|
||||||
|
style={{ padding: '12px 0' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 骨架屏分布卡片 */
|
||||||
|
function SkeletonBreakdownCard({ index }: { index: number }) {
|
||||||
|
return (
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<div
|
||||||
|
className="erp-content-card erp-fade-in"
|
||||||
|
style={{ animationDelay: `${0.05 * index}s`, opacity: 0 }}
|
||||||
|
>
|
||||||
|
<Skeleton active paragraph={{ rows: 4 }} />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 延迟类名工具 ──
|
||||||
|
|
||||||
|
const DELAY_CLASSES = [
|
||||||
|
'erp-fade-in erp-fade-in-delay-1',
|
||||||
|
'erp-fade-in erp-fade-in-delay-2',
|
||||||
|
'erp-fade-in erp-fade-in-delay-3',
|
||||||
|
'erp-fade-in erp-fade-in-delay-4',
|
||||||
|
'erp-fade-in erp-fade-in-delay-4',
|
||||||
|
];
|
||||||
|
|
||||||
|
function getDelayClass(index: number): string {
|
||||||
|
return DELAY_CLASSES[index % DELAY_CLASSES.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 主组件 ──
|
||||||
|
|
||||||
export function PluginDashboardPage() {
|
export function PluginDashboardPage() {
|
||||||
const { pluginId } = useParams<{ pluginId: string }>();
|
const { pluginId } = useParams<{ pluginId: string }>();
|
||||||
|
const { token: themeToken } = theme.useToken();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [schemaLoading, setSchemaLoading] = useState(false);
|
||||||
const [entities, setEntities] = useState<PluginEntitySchema[]>([]);
|
const [entities, setEntities] = useState<PluginEntitySchema[]>([]);
|
||||||
const [selectedEntity, setSelectedEntity] = useState<string>('');
|
const [selectedEntity, setSelectedEntity] = useState<string>('');
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [entityStats, setEntityStats] = useState<EntityStat[]>([]);
|
||||||
const [aggregations, setAggregations] = useState<AggregateItem[]>([]);
|
const [breakdowns, setBreakdowns] = useState<FieldBreakdown[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// 加载 schema 获取 entities
|
const isDark =
|
||||||
|
themeToken.colorBgContainer === '#111827' ||
|
||||||
|
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
|
||||||
|
|
||||||
|
// 加载 schema
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pluginId) return;
|
if (!pluginId) return;
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
async function loadSchema() {
|
async function loadSchema() {
|
||||||
|
setSchemaLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
|
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
|
||||||
if (abortController.signal.aborted) return;
|
if (abortController.signal.aborted) return;
|
||||||
@@ -37,7 +371,9 @@ export function PluginDashboardPage() {
|
|||||||
setSelectedEntity(entityList[0].name);
|
setSelectedEntity(entityList[0].name);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
message.warning('Schema 加载失败,部分功能不可用');
|
setError('Schema 加载失败,部分功能不可用');
|
||||||
|
} finally {
|
||||||
|
if (!abortController.signal.aborted) setSchemaLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,112 +381,266 @@ export function PluginDashboardPage() {
|
|||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, [pluginId]);
|
}, [pluginId]);
|
||||||
|
|
||||||
const currentEntity = entities.find((e) => e.name === selectedEntity);
|
const currentEntity = useMemo(
|
||||||
const filterableFields = currentEntity?.fields.filter((f) => f.filterable) || [];
|
() => entities.find((e) => e.name === selectedEntity),
|
||||||
|
[entities, selectedEntity],
|
||||||
|
);
|
||||||
|
|
||||||
// 使用后端 count/aggregate API
|
const filterableFields = useMemo(
|
||||||
|
() => currentEntity?.fields.filter((f) => f.filterable) || [],
|
||||||
|
[currentEntity],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 加载所有实体的计数
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pluginId || !selectedEntity) return;
|
if (!pluginId || entities.length === 0) return;
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
async function loadData() {
|
async function loadAllCounts() {
|
||||||
setLoading(true);
|
const results: EntityStat[] = [];
|
||||||
try {
|
for (const entity of entities) {
|
||||||
const total = await countPluginData(pluginId!, selectedEntity!);
|
|
||||||
if (abortController.signal.aborted) return;
|
if (abortController.signal.aborted) return;
|
||||||
setTotalCount(total);
|
try {
|
||||||
|
const count = await countPluginData(pluginId!, entity.name);
|
||||||
const aggs: AggregateItem[] = [];
|
|
||||||
for (const field of filterableFields) {
|
|
||||||
if (abortController.signal.aborted) return;
|
if (abortController.signal.aborted) return;
|
||||||
try {
|
const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE;
|
||||||
const items = await aggregatePluginData(pluginId!, selectedEntity!, field.name);
|
results.push({
|
||||||
for (const item of items) {
|
name: entity.name,
|
||||||
aggs.push({
|
displayName: entity.display_name || entity.name,
|
||||||
key: `${field.display_name || field.name}: ${item.key || '(空)'}`,
|
count,
|
||||||
count: item.count,
|
icon: ENTITY_ICONS[entity.name] || <DashboardOutlined />,
|
||||||
});
|
gradient: palette.gradient,
|
||||||
}
|
iconBg: palette.iconBg,
|
||||||
} catch {
|
});
|
||||||
// 单个字段聚合失败不影响其他字段
|
} catch {
|
||||||
}
|
const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE;
|
||||||
|
results.push({
|
||||||
|
name: entity.name,
|
||||||
|
displayName: entity.display_name || entity.name,
|
||||||
|
count: 0,
|
||||||
|
icon: ENTITY_ICONS[entity.name] || <DashboardOutlined />,
|
||||||
|
gradient: palette.gradient,
|
||||||
|
iconBg: palette.iconBg,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (abortController.signal.aborted) return;
|
|
||||||
setAggregations(aggs);
|
|
||||||
} catch {
|
|
||||||
message.warning('统计数据加载失败');
|
|
||||||
}
|
}
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
setEntityStats(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAllCounts();
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [pluginId, entities]);
|
||||||
|
|
||||||
|
// 当前实体的聚合数据
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!pluginId || !selectedEntity || filterableFields.length === 0) return;
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const totalCount = entityStats.find((s) => s.name === selectedEntity)?.count ?? 0;
|
||||||
|
const fieldResults: FieldBreakdown[] = [];
|
||||||
|
|
||||||
|
for (const field of filterableFields) {
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const items = await aggregatePluginData(pluginId!, selectedEntity!, field.name);
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
fieldResults.push({
|
||||||
|
fieldName: field.name,
|
||||||
|
displayName: field.display_name || field.name,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 单个字段聚合失败不影响其他字段
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
setBreakdowns(fieldResults);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('统计数据加载失败');
|
||||||
|
} finally {
|
||||||
if (!abortController.signal.aborted) setLoading(false);
|
if (!abortController.signal.aborted) setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadData();
|
|
||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, [pluginId, selectedEntity, filterableFields.length]);
|
}, [pluginId, selectedEntity, filterableFields, entityStats]);
|
||||||
|
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
useEffect(() => {
|
||||||
customer: <TeamOutlined />,
|
const cleanup = loadData();
|
||||||
contact: <TeamOutlined />,
|
return () => {
|
||||||
communication: <PhoneOutlined />,
|
cleanup?.then((fn) => fn?.()).catch(() => {});
|
||||||
customer_tag: <TagsOutlined />,
|
};
|
||||||
customer_relationship: <RiseOutlined />,
|
}, [loadData]);
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
// 当前选中实体的总数
|
||||||
return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>;
|
const currentTotal = useMemo(
|
||||||
|
() => entityStats.find((s) => s.name === selectedEntity)?.count ?? 0,
|
||||||
|
[entityStats, selectedEntity],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当前实体的色板
|
||||||
|
const currentPalette = useMemo(
|
||||||
|
() => ENTITY_PALETTE[selectedEntity] || DEFAULT_PALETTE,
|
||||||
|
[selectedEntity],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 渲染 ──
|
||||||
|
|
||||||
|
if (schemaLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<SkeletonStatCard key={i} delay={getDelayClass(i)} />
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
<Row gutter={[16, 16]} style={{ marginTop: 24 }}>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<SkeletonBreakdownCard key={i} index={i} />
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 24 }}>
|
||||||
<Card
|
{/* 页面标题 */}
|
||||||
title="统计概览"
|
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
|
||||||
size="small"
|
<div
|
||||||
extra={
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: isDark ? '#F1F5F9' : '#0F172A',
|
||||||
|
margin: '0 0 4px',
|
||||||
|
letterSpacing: '-0.5px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
统计概览
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: isDark ? '#94A3B8' : '#475569',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
CRM 数据全景视图,实时掌握业务动态
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={selectedEntity || undefined}
|
value={selectedEntity || undefined}
|
||||||
style={{ width: 150 }}
|
style={{ width: 160 }}
|
||||||
options={entities.map((e) => ({
|
options={entities.map((e) => ({
|
||||||
label: e.display_name || e.name,
|
label: e.display_name || e.name,
|
||||||
value: e.name,
|
value: e.name,
|
||||||
}))}
|
}))}
|
||||||
onChange={setSelectedEntity}
|
onChange={setSelectedEntity}
|
||||||
|
aria-label="选择实体类型"
|
||||||
/>
|
/>
|
||||||
}
|
</div>
|
||||||
>
|
</div>
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
<Col span={24}>
|
{/* 顶部统计卡片 */}
|
||||||
<Card>
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
<Statistic
|
{loading && entityStats.length === 0
|
||||||
title={currentEntity?.display_name || (selectedEntity ? selectedEntity + ' 总数' : '总数')}
|
? Array.from({ length: 5 }).map((_, i) => (
|
||||||
value={totalCount}
|
<SkeletonStatCard key={i} delay={getDelayClass(i)} />
|
||||||
prefix={selectedEntity ? (iconMap[selectedEntity] || <TeamOutlined />) : <TeamOutlined />}
|
))
|
||||||
valueStyle={{ color: '#4F46E5' }}
|
: entityStats.map((stat, i) => (
|
||||||
|
<StatCard
|
||||||
|
key={stat.name}
|
||||||
|
stat={stat}
|
||||||
|
loading={loading}
|
||||||
|
delay={getDelayClass(i)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
))}
|
||||||
</Col>
|
</Row>
|
||||||
|
|
||||||
{aggregations.length > 0 && (
|
{/* 分组统计区域 */}
|
||||||
<Col span={24}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Card title="分组统计" size="small">
|
<div className="erp-section-header">
|
||||||
<Row gutter={[8, 8]}>
|
<DashboardOutlined
|
||||||
{aggregations.slice(0, 20).map((agg, idx) => (
|
className="erp-section-icon"
|
||||||
<Col span={6} key={idx}>
|
style={{ color: currentPalette.tagColor === 'purple' ? '#4F46E5' : '#3B82F6' }}
|
||||||
<Tag color="blue" style={{ width: '100%', textAlign: 'center' }}>
|
/>
|
||||||
{agg.key}: {agg.count}
|
<span className="erp-section-title">
|
||||||
</Tag>
|
{currentEntity?.display_name || selectedEntity} 数据分布
|
||||||
</Col>
|
</span>
|
||||||
))}
|
<span
|
||||||
</Row>
|
style={{
|
||||||
</Card>
|
marginLeft: 'auto',
|
||||||
</Col>
|
fontSize: 12,
|
||||||
)}
|
color: 'var(--erp-text-tertiary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
共 {currentTotal.toLocaleString()} 条记录
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{aggregations.length === 0 && totalCount === 0 && (
|
{loading && breakdowns.length === 0 ? (
|
||||||
<Col span={24}>
|
<Row gutter={[16, 16]}>
|
||||||
<Empty description="暂无数据" />
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
</Col>
|
<SkeletonBreakdownCard key={i} index={i} />
|
||||||
)}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
) : breakdowns.length > 0 ? (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{breakdowns.map((bd, i) => (
|
||||||
|
<BreakdownCard
|
||||||
|
key={bd.fieldName}
|
||||||
|
breakdown={bd}
|
||||||
|
totalCount={currentTotal}
|
||||||
|
palette={currentPalette}
|
||||||
|
index={i}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
) : (
|
||||||
|
<div className="erp-content-card" style={{ textAlign: 'center', padding: '48px 24px' }}>
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={
|
||||||
|
filterableFields.length === 0
|
||||||
|
? '当前实体无可筛选项,暂无分布数据'
|
||||||
|
: '暂无数据'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: isDark ? 'rgba(220, 38, 38, 0.1)' : '#FEF2F2',
|
||||||
|
color: isDark ? '#FCA5A5' : '#991B1B',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -407,3 +407,19 @@ type = "crud"
|
|||||||
entity = "customer_relationship"
|
entity = "customer_relationship"
|
||||||
label = "客户关系"
|
label = "客户关系"
|
||||||
icon = "apartment"
|
icon = "apartment"
|
||||||
|
|
||||||
|
[[ui.pages]]
|
||||||
|
type = "graph"
|
||||||
|
entity = "customer"
|
||||||
|
label = "关系图谱"
|
||||||
|
icon = "apartment"
|
||||||
|
relationship_entity = "customer_relationship"
|
||||||
|
source_field = "from_customer_id"
|
||||||
|
target_field = "to_customer_id"
|
||||||
|
edge_label_field = "relationship_type"
|
||||||
|
node_label_field = "name"
|
||||||
|
|
||||||
|
[[ui.pages]]
|
||||||
|
type = "dashboard"
|
||||||
|
label = "统计概览"
|
||||||
|
icon = "DashboardOutlined"
|
||||||
|
|||||||
@@ -142,6 +142,24 @@ pub enum PluginPageType {
|
|||||||
icon: Option<String>,
|
icon: Option<String>,
|
||||||
tabs: Vec<PluginPageType>,
|
tabs: Vec<PluginPageType>,
|
||||||
},
|
},
|
||||||
|
#[serde(rename = "graph")]
|
||||||
|
Graph {
|
||||||
|
entity: String,
|
||||||
|
label: String,
|
||||||
|
#[serde(default)]
|
||||||
|
icon: Option<String>,
|
||||||
|
relationship_entity: String,
|
||||||
|
source_field: String,
|
||||||
|
target_field: String,
|
||||||
|
edge_label_field: String,
|
||||||
|
node_label_field: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "dashboard")]
|
||||||
|
Dashboard {
|
||||||
|
label: String,
|
||||||
|
#[serde(default)]
|
||||||
|
icon: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 插件页面区段(用于 detail 页面类型)
|
/// 插件页面区段(用于 detail 页面类型)
|
||||||
@@ -263,6 +281,27 @@ fn validate_pages(pages: &[PluginPageType]) -> PluginResult<()> {
|
|||||||
}
|
}
|
||||||
validate_pages(tabs)?;
|
validate_pages(tabs)?;
|
||||||
}
|
}
|
||||||
|
PluginPageType::Graph {
|
||||||
|
entity,
|
||||||
|
relationship_entity,
|
||||||
|
source_field,
|
||||||
|
target_field,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if entity.is_empty() || relationship_entity.is_empty() {
|
||||||
|
return Err(PluginError::InvalidManifest(
|
||||||
|
"graph page 的 entity/relationship_entity 不能为空".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if source_field.is_empty() || target_field.is_empty() {
|
||||||
|
return Err(PluginError::InvalidManifest(
|
||||||
|
"graph page 的 source_field/target_field 不能为空".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PluginPageType::Dashboard { .. } => {
|
||||||
|
// dashboard 无需额外验证
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
Reference in New Issue
Block a user