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:
iven
2026-04-17 01:28:19 +08:00
parent b08e8b5ab5
commit 2866ffb634
4 changed files with 1501 additions and 214 deletions

View File

@@ -1,33 +1,367 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
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 {
TeamOutlined,
RiseOutlined,
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';
/**
* 插件统计概览页面 — 通过路由参数自加载 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() {
const { pluginId } = useParams<{ pluginId: string }>();
const { token: themeToken } = theme.useToken();
const [loading, setLoading] = useState(false);
const [schemaLoading, setSchemaLoading] = useState(false);
const [entities, setEntities] = useState<PluginEntitySchema[]>([]);
const [selectedEntity, setSelectedEntity] = useState<string>('');
const [totalCount, setTotalCount] = useState(0);
const [aggregations, setAggregations] = useState<AggregateItem[]>([]);
const [entityStats, setEntityStats] = useState<EntityStat[]>([]);
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(() => {
if (!pluginId) return;
const abortController = new AbortController();
async function loadSchema() {
setSchemaLoading(true);
setError(null);
try {
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
if (abortController.signal.aborted) return;
@@ -37,7 +371,9 @@ export function PluginDashboardPage() {
setSelectedEntity(entityList[0].name);
}
} catch {
message.warning('Schema 加载失败,部分功能不可用');
setError('Schema 加载失败,部分功能不可用');
} finally {
if (!abortController.signal.aborted) setSchemaLoading(false);
}
}
@@ -45,112 +381,266 @@ export function PluginDashboardPage() {
return () => abortController.abort();
}, [pluginId]);
const currentEntity = entities.find((e) => e.name === selectedEntity);
const filterableFields = currentEntity?.fields.filter((f) => f.filterable) || [];
const currentEntity = useMemo(
() => entities.find((e) => e.name === selectedEntity),
[entities, selectedEntity],
);
// 使用后端 count/aggregate API
const filterableFields = useMemo(
() => currentEntity?.fields.filter((f) => f.filterable) || [],
[currentEntity],
);
// 加载所有实体的计数
useEffect(() => {
if (!pluginId || !selectedEntity) return;
if (!pluginId || entities.length === 0) return;
const abortController = new AbortController();
async function loadData() {
setLoading(true);
try {
const total = await countPluginData(pluginId!, selectedEntity!);
async function loadAllCounts() {
const results: EntityStat[] = [];
for (const entity of entities) {
if (abortController.signal.aborted) return;
setTotalCount(total);
const aggs: AggregateItem[] = [];
for (const field of filterableFields) {
try {
const count = await countPluginData(pluginId!, entity.name);
if (abortController.signal.aborted) return;
try {
const items = await aggregatePluginData(pluginId!, selectedEntity!, field.name);
for (const item of items) {
aggs.push({
key: `${field.display_name || field.name}: ${item.key || '(空)'}`,
count: item.count,
});
}
} catch {
// 单个字段聚合失败不影响其他字段
}
const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE;
results.push({
name: entity.name,
displayName: entity.display_name || entity.name,
count,
icon: ENTITY_ICONS[entity.name] || <DashboardOutlined />,
gradient: palette.gradient,
iconBg: palette.iconBg,
});
} catch {
const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE;
results.push({
name: entity.name,
displayName: entity.display_name || entity.name,
count: 0,
icon: ENTITY_ICONS[entity.name] || <DashboardOutlined />,
gradient: palette.gradient,
iconBg: palette.iconBg,
});
}
if (abortController.signal.aborted) 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);
}
loadData();
return () => abortController.abort();
}, [pluginId, selectedEntity, filterableFields.length]);
}, [pluginId, selectedEntity, filterableFields, entityStats]);
const iconMap: Record<string, React.ReactNode> = {
customer: <TeamOutlined />,
contact: <TeamOutlined />,
communication: <PhoneOutlined />,
customer_tag: <TagsOutlined />,
customer_relationship: <RiseOutlined />,
};
useEffect(() => {
const cleanup = loadData();
return () => {
cleanup?.then((fn) => fn?.()).catch(() => {});
};
}, [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 (
<div style={{ padding: 24 }}>
<Card
title="统计概览"
size="small"
extra={
{/* 页面标题 */}
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<h2
style={{
fontSize: 24,
fontWeight: 700,
color: isDark ? '#F1F5F9' : '#0F172A',
margin: '0 0 4px',
letterSpacing: '-0.5px',
}}
>
</h2>
<p
style={{
fontSize: 14,
color: isDark ? '#94A3B8' : '#475569',
margin: 0,
}}
>
CRM
</p>
</div>
<Select
value={selectedEntity || undefined}
style={{ width: 150 }}
style={{ width: 160 }}
options={entities.map((e) => ({
label: e.display_name || e.name,
value: e.name,
}))}
onChange={setSelectedEntity}
aria-label="选择实体类型"
/>
}
>
<Row gutter={[16, 16]}>
<Col span={24}>
<Card>
<Statistic
title={currentEntity?.display_name || (selectedEntity ? selectedEntity + ' 总数' : '总数')}
value={totalCount}
prefix={selectedEntity ? (iconMap[selectedEntity] || <TeamOutlined />) : <TeamOutlined />}
valueStyle={{ color: '#4F46E5' }}
</div>
</div>
{/* 顶部统计卡片 */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{loading && entityStats.length === 0
? Array.from({ length: 5 }).map((_, i) => (
<SkeletonStatCard key={i} delay={getDelayClass(i)} />
))
: entityStats.map((stat, i) => (
<StatCard
key={stat.name}
stat={stat}
loading={loading}
delay={getDelayClass(i)}
/>
</Card>
</Col>
))}
</Row>
{aggregations.length > 0 && (
<Col span={24}>
<Card title="分组统计" size="small">
<Row gutter={[8, 8]}>
{aggregations.slice(0, 20).map((agg, idx) => (
<Col span={6} key={idx}>
<Tag color="blue" style={{ width: '100%', textAlign: 'center' }}>
{agg.key}: {agg.count}
</Tag>
</Col>
))}
</Row>
</Card>
</Col>
)}
{/* 分组统计区域 */}
<div style={{ marginBottom: 16 }}>
<div className="erp-section-header">
<DashboardOutlined
className="erp-section-icon"
style={{ color: currentPalette.tagColor === 'purple' ? '#4F46E5' : '#3B82F6' }}
/>
<span className="erp-section-title">
{currentEntity?.display_name || selectedEntity}
</span>
<span
style={{
marginLeft: 'auto',
fontSize: 12,
color: 'var(--erp-text-tertiary)',
}}
>
{currentTotal.toLocaleString()}
</span>
</div>
</div>
{aggregations.length === 0 && totalCount === 0 && (
<Col span={24}>
<Empty description="暂无数据" />
</Col>
)}
{loading && breakdowns.length === 0 ? (
<Row gutter={[16, 16]}>
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonBreakdownCard key={i} index={i} />
))}
</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>
);
}