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:
iven
2026-04-17 11:26:52 +08:00
parent fb809f124c
commit b96978b588
4 changed files with 429 additions and 605 deletions

View File

@@ -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 { 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 {
TeamOutlined,
PhoneOutlined,
TagsOutlined,
RiseOutlined,
DashboardOutlined,
InfoCircleOutlined,
BarChartOutlined,
PieChartOutlined,
LineChartOutlined,
FunnelPlotOutlined,
} from '@ant-design/icons';
import { Column, Pie, Funnel, Line } from '@ant-design/charts';
import { countPluginData, aggregatePluginData, type AggregateItem } from '../api/pluginData';
import { getPluginSchema, type PluginEntitySchema, type PluginSchemaResponse, type PluginPageSchema, type DashboardWidget } from '../api/plugins';
// ── 类型定义 ──
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];
}
getPluginSchema,
type PluginEntitySchema,
type PluginSchemaResponse,
type PluginPageSchema,
type DashboardWidget,
} from '../api/plugins';
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboard/dashboardTypes';
import { ENTITY_PALETTE, DEFAULT_PALETTE, ENTITY_ICONS, getDelayClass } from './dashboard/dashboardConstants';
import {
StatCard,
SkeletonStatCard,
BreakdownCard,
SkeletonBreakdownCard,
WidgetRenderer,
} from './dashboard/DashboardWidgets';
// ── 主组件 ──
@@ -594,12 +33,10 @@ export function PluginDashboardPage() {
const [entityStats, setEntityStats] = useState<EntityStat[]>([]);
const [breakdowns, setBreakdowns] = useState<FieldBreakdown[]>([]);
const [error, setError] = useState<string | null>(null);
// Widget-based dashboard state
const [widgets, setWidgets] = useState<DashboardWidget[]>([]);
const [widgetData, setWidgetData] = useState<WidgetData[]>([]);
const [widgetsLoading, setWidgetsLoading] = useState(false);
const isDark =
themeToken.colorBgContainer === '#111827' ||
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
@@ -608,7 +45,6 @@ export function PluginDashboardPage() {
useEffect(() => {
if (!pluginId) return;
const abortController = new AbortController();
async function loadSchema() {
setSchemaLoading(true);
setError(null);
@@ -639,22 +75,18 @@ export function PluginDashboardPage() {
loadSchema();
return () => abortController.abort();
}, [pluginId]);
const currentEntity = useMemo(
() => entities.find((e) => e.name === selectedEntity),
[entities, selectedEntity],
);
const filterableFields = useMemo(
() => currentEntity?.fields.filter((f) => f.filterable) || [],
[currentEntity],
);
// 加载所有实体的计数
useEffect(() => {
if (!pluginId || entities.length === 0) return;
const abortController = new AbortController();
async function loadAllCounts() {
const results: EntityStat[] = [];
for (const entity of entities) {
@@ -691,12 +123,10 @@ export function PluginDashboardPage() {
loadAllCounts();
return () => abortController.abort();
}, [pluginId, entities]);
// Widget 数据并行加载
useEffect(() => {
if (!pluginId || widgets.length === 0) return;
const abortController = new AbortController();
async function loadWidgetData() {
setWidgetsLoading(true);
try {
@@ -734,18 +164,14 @@ export function PluginDashboardPage() {
loadWidgetData();
return () => abortController.abort();
}, [pluginId, widgets]);
// 当前实体的聚合数据
const loadData = useCallback(async () => {
if (!pluginId || !selectedEntity || filterableFields.length === 0) return;
const abortController = new AbortController();
setLoading(true);
setError(null);
try {
const fieldResults: FieldBreakdown[] = [];
for (const field of filterableFields) {
if (abortController.signal.aborted) return;
try {
@@ -761,39 +187,29 @@ export function PluginDashboardPage() {
}
}
if (!abortController.signal.aborted) {
setBreakdowns(fieldResults);
}
if (!abortController.signal.aborted) setBreakdowns(fieldResults);
} catch {
setError('统计数据加载失败');
} finally {
if (!abortController.signal.aborted) setLoading(false);
}
return () => abortController.abort();
}, [pluginId, selectedEntity, filterableFields, entityStats]);
useEffect(() => {
const cleanup = loadData();
return () => {
cleanup?.then((fn) => fn?.()).catch(() => {});
};
return () => { cleanup?.then((fn) => fn?.()).catch(() => {}); };
}, [loadData]);
// 当前选中实体的总数
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 }}>