refactor(web): 拆分 PluginDashboardPage 为 dashboard 子模块 — 每个文件 < 400 行
将 981 行的 PluginDashboardPage.tsx 拆分为 4 个文件: - dashboard/dashboardTypes.ts (25 行) — 类型定义 - dashboard/dashboardConstants.ts (85 行) — 常量和配置 - dashboard/DashboardWidgets.tsx (298 行) — Widget 子组件 + 共享工具 - PluginDashboardPage.tsx (397 行) — 页面壳 提取了 prepareChartData / WidgetCardShell / tagStrokeColor 等共享工具, 消除了图表组件间的重复代码。tsc --noEmit 通过。
This commit is contained in:
@@ -1,585 +1,24 @@
|
|||||||
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Row, Col, Spin, Empty, Select, Tag, Progress, Skeleton, theme, Tooltip, Card, Typography } from 'antd';
|
import { Row, Col, Empty, Select, Spin, theme } from 'antd';
|
||||||
|
import { DashboardOutlined } from '@ant-design/icons';
|
||||||
|
import { countPluginData, aggregatePluginData } from '../api/pluginData';
|
||||||
import {
|
import {
|
||||||
TeamOutlined,
|
getPluginSchema,
|
||||||
PhoneOutlined,
|
type PluginEntitySchema,
|
||||||
TagsOutlined,
|
type PluginSchemaResponse,
|
||||||
RiseOutlined,
|
type PluginPageSchema,
|
||||||
DashboardOutlined,
|
type DashboardWidget,
|
||||||
InfoCircleOutlined,
|
} from '../api/plugins';
|
||||||
BarChartOutlined,
|
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboard/dashboardTypes';
|
||||||
PieChartOutlined,
|
import { ENTITY_PALETTE, DEFAULT_PALETTE, ENTITY_ICONS, getDelayClass } from './dashboard/dashboardConstants';
|
||||||
LineChartOutlined,
|
import {
|
||||||
FunnelPlotOutlined,
|
StatCard,
|
||||||
} from '@ant-design/icons';
|
SkeletonStatCard,
|
||||||
import { Column, Pie, Funnel, Line } from '@ant-design/charts';
|
BreakdownCard,
|
||||||
import { countPluginData, aggregatePluginData, type AggregateItem } from '../api/pluginData';
|
SkeletonBreakdownCard,
|
||||||
import { getPluginSchema, type PluginEntitySchema, type PluginSchemaResponse, type PluginPageSchema, type DashboardWidget } from '../api/plugins';
|
WidgetRenderer,
|
||||||
|
} from './dashboard/DashboardWidgets';
|
||||||
// ── 类型定义 ──
|
|
||||||
|
|
||||||
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,
|
|
||||||
index,
|
|
||||||
}: {
|
|
||||||
breakdown: FieldBreakdown;
|
|
||||||
totalCount: number;
|
|
||||||
index: number;
|
|
||||||
}) {
|
|
||||||
const maxCount = Math.max(...breakdown.items.map((i) => i.count), 1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Col xs={24} sm={12} lg={8} key={breakdown.fieldName}>
|
|
||||||
<div
|
|
||||||
className={`erp-content-card erp-fade-in`}
|
|
||||||
style={{ animationDelay: `${0.05 * index}s`, opacity: 0 }}
|
|
||||||
>
|
|
||||||
<div className="erp-section-header" style={{ marginBottom: 16 }}>
|
|
||||||
<InfoCircleOutlined className="erp-section-icon" style={{ fontSize: 14 }} />
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--erp-text-primary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{breakdown.displayName}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: 'auto',
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'var(--erp-text-tertiary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{breakdown.items.length} 项
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
||||||
{breakdown.items.map((item, idx) => {
|
|
||||||
const percent = totalCount > 0 ? Math.round((item.count / totalCount) * 100) : 0;
|
|
||||||
const barPercent = maxCount > 0 ? Math.round((item.count / maxCount) * 100) : 0;
|
|
||||||
const color = TAG_COLORS[idx % TAG_COLORS.length];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={`${breakdown.fieldName}-${item.key}-${idx}`}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip title={`${item.key}: ${item.count} (${percent}%)`}>
|
|
||||||
<Tag
|
|
||||||
color={color}
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
maxWidth: '60%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.key || '(空)'}
|
|
||||||
</Tag>
|
|
||||||
</Tooltip>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--erp-text-primary)',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.count}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 400,
|
|
||||||
color: 'var(--erp-text-tertiary)',
|
|
||||||
marginLeft: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{percent}%
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
percent={barPercent}
|
|
||||||
showInfo={false}
|
|
||||||
strokeColor={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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Widget 图表子组件 ──
|
|
||||||
|
|
||||||
const WIDGET_ICON_MAP: Record<string, React.ReactNode> = {
|
|
||||||
stat_card: <DashboardOutlined />,
|
|
||||||
bar_chart: <BarChartOutlined />,
|
|
||||||
pie_chart: <PieChartOutlined />,
|
|
||||||
funnel_chart: <FunnelPlotOutlined />,
|
|
||||||
line_chart: <LineChartOutlined />,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface WidgetData {
|
|
||||||
widget: DashboardWidget;
|
|
||||||
data: AggregateItem[];
|
|
||||||
count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 统计卡片 widget */
|
|
||||||
function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) {
|
|
||||||
const { widget, count } = widgetData;
|
|
||||||
const animatedValue = useCountUp(count ?? 0);
|
|
||||||
const color = widget.color || '#4F46E5';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
className="erp-fade-in"
|
|
||||||
style={{ height: '100%' }}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 10,
|
|
||||||
background: `${color}18`,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color,
|
|
||||||
fontSize: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{WIDGET_ICON_MAP[widget.type] || <DashboardOutlined />}
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
{widget.title}
|
|
||||||
</Typography.Text>
|
|
||||||
<div style={{ fontSize: 24, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
|
||||||
{animatedValue.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 柱状图 widget */
|
|
||||||
function BarWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
|
|
||||||
const { widget, data } = widgetData;
|
|
||||||
const dimensionOrder = widget.dimension_order;
|
|
||||||
|
|
||||||
const chartData = dimensionOrder
|
|
||||||
? dimensionOrder
|
|
||||||
.map((key) => data.find((d) => d.key === key))
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((d) => ({ key: d!.key, count: d!.count }))
|
|
||||||
: data.map((d) => ({ key: d.key, count: d.count }));
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
data: chartData,
|
|
||||||
xField: 'key',
|
|
||||||
yField: 'count',
|
|
||||||
colorField: 'key',
|
|
||||||
style: { maxWidth: 40, maxWidthRatio: 0.6 },
|
|
||||||
axis: {
|
|
||||||
x: { label: { style: { fill: isDark ? '#94A3B8' : '#475569' } } },
|
|
||||||
y: { label: { style: { fill: isDark ? '#94A3B8' : '#475569' } } },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title={
|
|
||||||
<span style={{ fontSize: 14 }}>
|
|
||||||
{WIDGET_ICON_MAP[widget.type]} {widget.title}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
className="erp-fade-in"
|
|
||||||
>
|
|
||||||
{chartData.length > 0 ? (
|
|
||||||
<Column {...config} />
|
|
||||||
) : (
|
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 饼图 widget */
|
|
||||||
function PieWidgetCard({ widgetData }: { widgetData: WidgetData }) {
|
|
||||||
const { widget, data } = widgetData;
|
|
||||||
|
|
||||||
const chartData = data.map((d) => ({ key: d.key, count: d.count }));
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
data: chartData,
|
|
||||||
angleField: 'count',
|
|
||||||
colorField: 'key',
|
|
||||||
radius: 0.8,
|
|
||||||
innerRadius: 0.5,
|
|
||||||
label: {
|
|
||||||
text: 'key',
|
|
||||||
position: 'outside' as const,
|
|
||||||
},
|
|
||||||
legend: { color: { position: 'bottom' as const } },
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title={
|
|
||||||
<span style={{ fontSize: 14 }}>
|
|
||||||
{WIDGET_ICON_MAP[widget.type]} {widget.title}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
className="erp-fade-in"
|
|
||||||
>
|
|
||||||
{chartData.length > 0 ? (
|
|
||||||
<Pie {...config} />
|
|
||||||
) : (
|
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 漏斗图 widget */
|
|
||||||
function FunnelWidgetCard({ widgetData }: { widgetData: WidgetData }) {
|
|
||||||
const { widget, data } = widgetData;
|
|
||||||
const dimensionOrder = widget.dimension_order;
|
|
||||||
|
|
||||||
const chartData = dimensionOrder
|
|
||||||
? dimensionOrder
|
|
||||||
.map((key) => data.find((d) => d.key === key))
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((d) => ({ key: d!.key, count: d!.count }))
|
|
||||||
: data.map((d) => ({ key: d.key, count: d.count }));
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
data: chartData,
|
|
||||||
xField: 'key',
|
|
||||||
yField: 'count',
|
|
||||||
legend: { position: 'bottom' as const },
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title={
|
|
||||||
<span style={{ fontSize: 14 }}>
|
|
||||||
{WIDGET_ICON_MAP[widget.type]} {widget.title}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
className="erp-fade-in"
|
|
||||||
>
|
|
||||||
{chartData.length > 0 ? (
|
|
||||||
<Funnel {...config} />
|
|
||||||
) : (
|
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 折线图 widget */
|
|
||||||
function LineWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
|
|
||||||
const { widget, data } = widgetData;
|
|
||||||
const dimensionOrder = widget.dimension_order;
|
|
||||||
|
|
||||||
const chartData = dimensionOrder
|
|
||||||
? dimensionOrder
|
|
||||||
.map((key) => data.find((d) => d.key === key))
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((d) => ({ key: d!.key, count: d!.count }))
|
|
||||||
: data.map((d) => ({ key: d.key, count: d.count }));
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
data: chartData,
|
|
||||||
xField: 'key',
|
|
||||||
yField: 'count',
|
|
||||||
smooth: true,
|
|
||||||
axis: {
|
|
||||||
x: { label: { style: { fill: isDark ? '#94A3B8' : '#475569' } } },
|
|
||||||
y: { label: { style: { fill: isDark ? '#94A3B8' : '#475569' } } },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title={
|
|
||||||
<span style={{ fontSize: 14 }}>
|
|
||||||
{WIDGET_ICON_MAP[widget.type]} {widget.title}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
className="erp-fade-in"
|
|
||||||
>
|
|
||||||
{chartData.length > 0 ? (
|
|
||||||
<Line {...config} />
|
|
||||||
) : (
|
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 渲染单个 widget */
|
|
||||||
function WidgetRenderer({
|
|
||||||
widgetData,
|
|
||||||
isDark,
|
|
||||||
}: {
|
|
||||||
widgetData: WidgetData;
|
|
||||||
isDark: boolean;
|
|
||||||
}) {
|
|
||||||
switch (widgetData.widget.type) {
|
|
||||||
case 'stat_card':
|
|
||||||
return <StatWidgetCard widgetData={widgetData} />;
|
|
||||||
case 'bar_chart':
|
|
||||||
return <BarWidgetCard widgetData={widgetData} isDark={isDark} />;
|
|
||||||
case 'pie_chart':
|
|
||||||
return <PieWidgetCard widgetData={widgetData} />;
|
|
||||||
case 'funnel_chart':
|
|
||||||
return <FunnelWidgetCard widgetData={widgetData} />;
|
|
||||||
case 'line_chart':
|
|
||||||
return <LineWidgetCard widgetData={widgetData} isDark={isDark} />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 延迟类名工具 ──
|
|
||||||
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 主组件 ──
|
// ── 主组件 ──
|
||||||
|
|
||||||
@@ -594,12 +33,10 @@ export function PluginDashboardPage() {
|
|||||||
const [entityStats, setEntityStats] = useState<EntityStat[]>([]);
|
const [entityStats, setEntityStats] = useState<EntityStat[]>([]);
|
||||||
const [breakdowns, setBreakdowns] = useState<FieldBreakdown[]>([]);
|
const [breakdowns, setBreakdowns] = useState<FieldBreakdown[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Widget-based dashboard state
|
// Widget-based dashboard state
|
||||||
const [widgets, setWidgets] = useState<DashboardWidget[]>([]);
|
const [widgets, setWidgets] = useState<DashboardWidget[]>([]);
|
||||||
const [widgetData, setWidgetData] = useState<WidgetData[]>([]);
|
const [widgetData, setWidgetData] = useState<WidgetData[]>([]);
|
||||||
const [widgetsLoading, setWidgetsLoading] = useState(false);
|
const [widgetsLoading, setWidgetsLoading] = useState(false);
|
||||||
|
|
||||||
const isDark =
|
const isDark =
|
||||||
themeToken.colorBgContainer === '#111827' ||
|
themeToken.colorBgContainer === '#111827' ||
|
||||||
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
|
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
|
||||||
@@ -608,7 +45,6 @@ export function PluginDashboardPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pluginId) return;
|
if (!pluginId) return;
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
async function loadSchema() {
|
async function loadSchema() {
|
||||||
setSchemaLoading(true);
|
setSchemaLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -639,22 +75,18 @@ export function PluginDashboardPage() {
|
|||||||
loadSchema();
|
loadSchema();
|
||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, [pluginId]);
|
}, [pluginId]);
|
||||||
|
|
||||||
const currentEntity = useMemo(
|
const currentEntity = useMemo(
|
||||||
() => entities.find((e) => e.name === selectedEntity),
|
() => entities.find((e) => e.name === selectedEntity),
|
||||||
[entities, selectedEntity],
|
[entities, selectedEntity],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filterableFields = useMemo(
|
const filterableFields = useMemo(
|
||||||
() => currentEntity?.fields.filter((f) => f.filterable) || [],
|
() => currentEntity?.fields.filter((f) => f.filterable) || [],
|
||||||
[currentEntity],
|
[currentEntity],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 加载所有实体的计数
|
// 加载所有实体的计数
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pluginId || entities.length === 0) return;
|
if (!pluginId || entities.length === 0) return;
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
async function loadAllCounts() {
|
async function loadAllCounts() {
|
||||||
const results: EntityStat[] = [];
|
const results: EntityStat[] = [];
|
||||||
for (const entity of entities) {
|
for (const entity of entities) {
|
||||||
@@ -691,12 +123,10 @@ export function PluginDashboardPage() {
|
|||||||
loadAllCounts();
|
loadAllCounts();
|
||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, [pluginId, entities]);
|
}, [pluginId, entities]);
|
||||||
|
|
||||||
// Widget 数据并行加载
|
// Widget 数据并行加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pluginId || widgets.length === 0) return;
|
if (!pluginId || widgets.length === 0) return;
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
async function loadWidgetData() {
|
async function loadWidgetData() {
|
||||||
setWidgetsLoading(true);
|
setWidgetsLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -734,18 +164,14 @@ export function PluginDashboardPage() {
|
|||||||
loadWidgetData();
|
loadWidgetData();
|
||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, [pluginId, widgets]);
|
}, [pluginId, widgets]);
|
||||||
|
|
||||||
// 当前实体的聚合数据
|
// 当前实体的聚合数据
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!pluginId || !selectedEntity || filterableFields.length === 0) return;
|
if (!pluginId || !selectedEntity || filterableFields.length === 0) return;
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fieldResults: FieldBreakdown[] = [];
|
const fieldResults: FieldBreakdown[] = [];
|
||||||
|
|
||||||
for (const field of filterableFields) {
|
for (const field of filterableFields) {
|
||||||
if (abortController.signal.aborted) return;
|
if (abortController.signal.aborted) return;
|
||||||
try {
|
try {
|
||||||
@@ -761,39 +187,29 @@ export function PluginDashboardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) setBreakdowns(fieldResults);
|
||||||
setBreakdowns(fieldResults);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('统计数据加载失败');
|
setError('统计数据加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
if (!abortController.signal.aborted) setLoading(false);
|
if (!abortController.signal.aborted) setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, [pluginId, selectedEntity, filterableFields, entityStats]);
|
}, [pluginId, selectedEntity, filterableFields, entityStats]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanup = loadData();
|
const cleanup = loadData();
|
||||||
return () => {
|
return () => { cleanup?.then((fn) => fn?.()).catch(() => {}); };
|
||||||
cleanup?.then((fn) => fn?.()).catch(() => {});
|
|
||||||
};
|
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
// 当前选中实体的总数
|
// 当前选中实体的总数
|
||||||
const currentTotal = useMemo(
|
const currentTotal = useMemo(
|
||||||
() => entityStats.find((s) => s.name === selectedEntity)?.count ?? 0,
|
() => entityStats.find((s) => s.name === selectedEntity)?.count ?? 0,
|
||||||
[entityStats, selectedEntity],
|
[entityStats, selectedEntity],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 当前实体的色板
|
// 当前实体的色板
|
||||||
const currentPalette = useMemo(
|
const currentPalette = useMemo(
|
||||||
() => ENTITY_PALETTE[selectedEntity] || DEFAULT_PALETTE,
|
() => ENTITY_PALETTE[selectedEntity] || DEFAULT_PALETTE,
|
||||||
[selectedEntity],
|
[selectedEntity],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 渲染 ──
|
// ── 渲染 ──
|
||||||
|
|
||||||
if (schemaLoading) {
|
if (schemaLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 24 }}>
|
||||||
|
|||||||
298
apps/web/src/pages/dashboard/DashboardWidgets.tsx
Normal file
298
apps/web/src/pages/dashboard/DashboardWidgets.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { Col, Spin, Empty, Tag, Progress, Skeleton, Tooltip, Card, Typography } from 'antd';
|
||||||
|
import {
|
||||||
|
InfoCircleOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Column, Pie, Funnel, Line } from '@ant-design/charts';
|
||||||
|
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboardTypes';
|
||||||
|
import { TAG_COLORS, WIDGET_ICON_MAP } from './dashboardConstants';
|
||||||
|
|
||||||
|
// ── 计数动画 Hook ──
|
||||||
|
|
||||||
|
function useCountUp(end: number, duration = 800) {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const prevEnd = useRef(end);
|
||||||
|
useEffect(() => {
|
||||||
|
if (end === prevEnd.current && count > 0) return;
|
||||||
|
prevEnd.current = end;
|
||||||
|
if (end === 0) { setCount(0); return; }
|
||||||
|
const startTime = performance.now();
|
||||||
|
function tick(now: number) {
|
||||||
|
const elapsed = now - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
setCount(Math.round(end * eased));
|
||||||
|
if (progress < 1) requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}, [end, duration]);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 共享工具 ──
|
||||||
|
|
||||||
|
function prepareChartData(data: WidgetData['data'], dimensionOrder?: string[]) {
|
||||||
|
return dimensionOrder
|
||||||
|
? dimensionOrder
|
||||||
|
.map((key) => data.find((d) => d.key === key))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((d) => ({ key: d!.key, count: d!.count }))
|
||||||
|
: data.map((d) => ({ key: d.key, count: d.count }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG_COLOR_MAP: Record<string, string> = {
|
||||||
|
blue: '#3B82F6', green: '#10B981', orange: '#F59E0B', red: '#EF4444',
|
||||||
|
purple: '#8B5CF6', cyan: '#06B6D4', magenta: '#EC4899', gold: '#EAB308',
|
||||||
|
lime: '#84CC16', geekblue: '#6366F1', volcano: '#F97316',
|
||||||
|
};
|
||||||
|
|
||||||
|
function tagStrokeColor(color: string): string {
|
||||||
|
return TAG_COLOR_MAP[color] || '#3B82F6';
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetCardShell({
|
||||||
|
title,
|
||||||
|
widgetType,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
widgetType: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP[widgetType]} {title}</span>}
|
||||||
|
className="erp-fade-in"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartEmpty() {
|
||||||
|
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 基础子组件 ──
|
||||||
|
|
||||||
|
function StatValue({ value, loading }: { value: number; loading: boolean }) {
|
||||||
|
const animatedValue = useCountUp(value);
|
||||||
|
if (loading) return <Spin size="small" />;
|
||||||
|
return <span className="erp-count-up">{animatedValue.toLocaleString()}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部统计卡片 */
|
||||||
|
export function StatCard({ stat, loading, delay }: { stat: EntityStat; loading: boolean; delay: string }) {
|
||||||
|
return (
|
||||||
|
<Col xs={12} sm={8} lg={Math.max(4, 24 / 5)} key={stat.name}>
|
||||||
|
<div
|
||||||
|
className={`erp-stat-card ${delay}`}
|
||||||
|
style={{ '--card-gradient': stat.gradient, '--card-icon-bg': stat.iconBg } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<div className="erp-stat-card-bar" />
|
||||||
|
<div className="erp-stat-card-body">
|
||||||
|
<div className="erp-stat-card-info">
|
||||||
|
<div className="erp-stat-card-title">{stat.displayName}</div>
|
||||||
|
<div className="erp-stat-card-value">
|
||||||
|
<StatValue value={stat.count} loading={loading} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="erp-stat-card-icon">{stat.icon}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 骨架屏卡片 */
|
||||||
|
export function SkeletonStatCard({ delay }: { delay: string }) {
|
||||||
|
return (
|
||||||
|
<Col xs={12} sm={8} lg={Math.max(4, 24 / 5)}>
|
||||||
|
<div className={`erp-stat-card ${delay}`}>
|
||||||
|
<div className="erp-stat-card-bar" style={{ opacity: 0.3 }} />
|
||||||
|
<div className="erp-stat-card-body">
|
||||||
|
<div className="erp-stat-card-info">
|
||||||
|
<div style={{ width: 80, height: 14, marginBottom: 12 }}>
|
||||||
|
<Skeleton.Input active size="small" style={{ width: 80, height: 14 }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 60, height: 32 }}>
|
||||||
|
<Skeleton.Input active style={{ width: 60, height: 32 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 48, height: 48 }}>
|
||||||
|
<Skeleton.Avatar active shape="square" size={48} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字段分布卡片 */
|
||||||
|
export function BreakdownCard({
|
||||||
|
breakdown, totalCount, index,
|
||||||
|
}: { breakdown: FieldBreakdown; totalCount: number; index: number }) {
|
||||||
|
const maxCount = Math.max(...breakdown.items.map((i) => i.count), 1);
|
||||||
|
return (
|
||||||
|
<Col xs={24} sm={12} lg={8} key={breakdown.fieldName}>
|
||||||
|
<div className="erp-content-card erp-fade-in" style={{ animationDelay: `${0.05 * index}s`, opacity: 0 }}>
|
||||||
|
<div className="erp-section-header" style={{ marginBottom: 16 }}>
|
||||||
|
<InfoCircleOutlined className="erp-section-icon" style={{ fontSize: 14 }} />
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--erp-text-primary)' }}>
|
||||||
|
{breakdown.displayName}
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--erp-text-tertiary)' }}>
|
||||||
|
{breakdown.items.length} 项
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{breakdown.items.map((item, idx) => {
|
||||||
|
const percent = totalCount > 0 ? Math.round((item.count / totalCount) * 100) : 0;
|
||||||
|
const barPercent = maxCount > 0 ? Math.round((item.count / maxCount) * 100) : 0;
|
||||||
|
const color = TAG_COLORS[idx % TAG_COLORS.length];
|
||||||
|
return (
|
||||||
|
<div key={`${breakdown.fieldName}-${item.key}-${idx}`}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<Tooltip title={`${item.key}: ${item.count} (${percent}%)`}>
|
||||||
|
<Tag color={color} style={{ margin: 0, maxWidth: '60%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{item.key || '(空)'}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--erp-text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{item.count}
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 400, color: 'var(--erp-text-tertiary)', marginLeft: 4 }}>{percent}%</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={barPercent}
|
||||||
|
showInfo={false}
|
||||||
|
strokeColor={tagStrokeColor(color)}
|
||||||
|
trailColor="var(--erp-border-light)"
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{breakdown.items.length === 0 && (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" style={{ padding: '12px 0' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 骨架屏分布卡片 */
|
||||||
|
export function SkeletonBreakdownCard({ index }: { index: number }) {
|
||||||
|
return (
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<div className="erp-content-card erp-fade-in" style={{ animationDelay: `${0.05 * index}s`, opacity: 0 }}>
|
||||||
|
<Skeleton active paragraph={{ rows: 4 }} />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Widget 图表子组件 ──
|
||||||
|
|
||||||
|
/** 统计卡片 widget */
|
||||||
|
function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) {
|
||||||
|
const { widget, count } = widgetData;
|
||||||
|
const animatedValue = useCountUp(count ?? 0);
|
||||||
|
const color = widget.color || '#4F46E5';
|
||||||
|
return (
|
||||||
|
<Card size="small" className="erp-fade-in" style={{ height: '100%' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 40, height: 40, borderRadius: 10, background: `${color}18`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', color, fontSize: 20,
|
||||||
|
}}>
|
||||||
|
{WIDGET_ICON_MAP[widget.type] || <DashboardOutlined />}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{widget.title}</Typography.Text>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{animatedValue.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 柱状图 widget */
|
||||||
|
function BarWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
|
||||||
|
const { widget, data } = widgetData;
|
||||||
|
const chartData = prepareChartData(data, widget.dimension_order);
|
||||||
|
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' };
|
||||||
|
return (
|
||||||
|
<WidgetCardShell title={widget.title} widgetType={widget.type}>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<Column data={chartData} xField="key" yField="count" colorField="key"
|
||||||
|
style={{ maxWidth: 40, maxWidthRatio: 0.6 }}
|
||||||
|
axis={{ x: { label: { style: axisLabelStyle } }, y: { label: { style: axisLabelStyle } } }}
|
||||||
|
/>
|
||||||
|
) : <ChartEmpty />}
|
||||||
|
</WidgetCardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 饼图 widget */
|
||||||
|
function PieWidgetCard({ widgetData }: { widgetData: WidgetData }) {
|
||||||
|
const { widget, data } = widgetData;
|
||||||
|
const chartData = data.map((d) => ({ key: d.key, count: d.count }));
|
||||||
|
return (
|
||||||
|
<WidgetCardShell title={widget.title} widgetType={widget.type}>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<Pie data={chartData} angleField="count" colorField="key" radius={0.8} innerRadius={0.5}
|
||||||
|
label={{ text: 'key', position: 'outside' as const }}
|
||||||
|
legend={{ color: { position: 'bottom' as const } }}
|
||||||
|
/>
|
||||||
|
) : <ChartEmpty />}
|
||||||
|
</WidgetCardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 漏斗图 widget */
|
||||||
|
function FunnelWidgetCard({ widgetData }: { widgetData: WidgetData }) {
|
||||||
|
const { widget, data } = widgetData;
|
||||||
|
const chartData = prepareChartData(data, widget.dimension_order);
|
||||||
|
return (
|
||||||
|
<WidgetCardShell title={widget.title} widgetType={widget.type}>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<Funnel data={chartData} xField="key" yField="count" legend={{ position: 'bottom' as const }} />
|
||||||
|
) : <ChartEmpty />}
|
||||||
|
</WidgetCardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 折线图 widget */
|
||||||
|
function LineWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
|
||||||
|
const { widget, data } = widgetData;
|
||||||
|
const chartData = prepareChartData(data, widget.dimension_order);
|
||||||
|
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' };
|
||||||
|
return (
|
||||||
|
<WidgetCardShell title={widget.title} widgetType={widget.type}>
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<Line data={chartData} xField="key" yField="count" smooth
|
||||||
|
axis={{ x: { label: { style: axisLabelStyle } }, y: { label: { style: axisLabelStyle } } }}
|
||||||
|
/>
|
||||||
|
) : <ChartEmpty />}
|
||||||
|
</WidgetCardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染单个 widget */
|
||||||
|
export function WidgetRenderer({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
|
||||||
|
switch (widgetData.widget.type) {
|
||||||
|
case 'stat_card': return <StatWidgetCard widgetData={widgetData} />;
|
||||||
|
case 'bar_chart': return <BarWidgetCard widgetData={widgetData} isDark={isDark} />;
|
||||||
|
case 'pie_chart': return <PieWidgetCard widgetData={widgetData} />;
|
||||||
|
case 'funnel_chart': return <FunnelWidgetCard widgetData={widgetData} />;
|
||||||
|
case 'line_chart': return <LineWidgetCard widgetData={widgetData} isDark={isDark} />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
apps/web/src/pages/dashboard/dashboardConstants.ts
Normal file
85
apps/web/src/pages/dashboard/dashboardConstants.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
import {
|
||||||
|
TeamOutlined,
|
||||||
|
PhoneOutlined,
|
||||||
|
TagsOutlined,
|
||||||
|
RiseOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
PieChartOutlined,
|
||||||
|
LineChartOutlined,
|
||||||
|
FunnelPlotOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
// ── 色板配置 ──
|
||||||
|
|
||||||
|
export const ENTITY_PALETTE: Record<string, { gradient: string; iconBg: string; tagColor: string }> = {
|
||||||
|
customer: {
|
||||||
|
gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)',
|
||||||
|
iconBg: 'rgba(79, 70, 229, 0.12)',
|
||||||
|
tagColor: 'purple',
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
gradient: 'linear-gradient(135deg, #059669, #10B981)',
|
||||||
|
iconBg: 'rgba(5, 150, 105, 0.12)',
|
||||||
|
tagColor: 'green',
|
||||||
|
},
|
||||||
|
communication: {
|
||||||
|
gradient: 'linear-gradient(135deg, #D97706, #F59E0B)',
|
||||||
|
iconBg: 'rgba(217, 119, 6, 0.12)',
|
||||||
|
tagColor: 'orange',
|
||||||
|
},
|
||||||
|
customer_tag: {
|
||||||
|
gradient: 'linear-gradient(135deg, #7C3AED, #A78BFA)',
|
||||||
|
iconBg: 'rgba(124, 58, 237, 0.12)',
|
||||||
|
tagColor: 'volcano',
|
||||||
|
},
|
||||||
|
customer_relationship: {
|
||||||
|
gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)',
|
||||||
|
iconBg: 'rgba(225, 29, 72, 0.12)',
|
||||||
|
tagColor: 'red',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_PALETTE = {
|
||||||
|
gradient: 'linear-gradient(135deg, #2563EB, #3B82F6)',
|
||||||
|
iconBg: 'rgba(37, 99, 235, 0.12)',
|
||||||
|
tagColor: 'blue',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TAG_COLORS = [
|
||||||
|
'blue', 'green', 'orange', 'red', 'purple', 'cyan',
|
||||||
|
'magenta', 'gold', 'lime', 'geekblue', 'volcano',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── 图标映射 ──
|
||||||
|
|
||||||
|
export const ENTITY_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
customer: <TeamOutlined />,
|
||||||
|
contact: <TeamOutlined />,
|
||||||
|
communication: <PhoneOutlined />,
|
||||||
|
customer_tag: <TagsOutlined />,
|
||||||
|
customer_relationship: <RiseOutlined />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WIDGET_ICON_MAP: Record<string, React.ReactNode> = {
|
||||||
|
stat_card: <DashboardOutlined />,
|
||||||
|
bar_chart: <BarChartOutlined />,
|
||||||
|
pie_chart: <PieChartOutlined />,
|
||||||
|
funnel_chart: <FunnelPlotOutlined />,
|
||||||
|
line_chart: <LineChartOutlined />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 延迟类名工具 ──
|
||||||
|
|
||||||
|
const DELAY_CLASSES = [
|
||||||
|
'erp-fade-in erp-fade-in-delay-1',
|
||||||
|
'erp-fade-in erp-fade-in-delay-2',
|
||||||
|
'erp-fade-in erp-fade-in-delay-3',
|
||||||
|
'erp-fade-in erp-fade-in-delay-4',
|
||||||
|
'erp-fade-in erp-fade-in-delay-4',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getDelayClass(index: number): string {
|
||||||
|
return DELAY_CLASSES[index % DELAY_CLASSES.length];
|
||||||
|
}
|
||||||
25
apps/web/src/pages/dashboard/dashboardTypes.ts
Normal file
25
apps/web/src/pages/dashboard/dashboardTypes.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
import type { AggregateItem, DashboardWidget } from '../../api/plugins';
|
||||||
|
|
||||||
|
// ── 类型定义 ──
|
||||||
|
|
||||||
|
export interface EntityStat {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
count: number;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
gradient: string;
|
||||||
|
iconBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldBreakdown {
|
||||||
|
fieldName: string;
|
||||||
|
displayName: string;
|
||||||
|
items: AggregateItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetData {
|
||||||
|
widget: DashboardWidget;
|
||||||
|
data: AggregateItem[];
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user