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

@@ -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;
}
}