从 Pinterest 风格切换到 Soft UI Evolution 设计系统,使用 UI UX Pro Max 推理引擎生成适合跨行业 ERP 业务用户的专业设计方案。 设计变更: - 主色从 Pinterest Red (#e60023) 切换到 Trust Blue (#2563EB) - 字体从系统默认切换到 Noto Sans SC(中文优先) - 圆角从 16-20px 调整到 10-12px(专业但不夸张) - 中性色从暖橄榄调切换到 Slate 石板蓝调 - 成功色 #103c25 → #059669,警告色 #b56e1a → #d97706 - 暗色模式从暖黑 (#1a1a18) 切换到深海军蓝 (#0f172a) 涉及文件:DESIGN.md + index.css + App.tsx + 24 个组件文件
441 lines
17 KiB
TypeScript
441 lines
17 KiB
TypeScript
import { useEffect, useState, useRef } from 'react';
|
|
import { Col, Row, Spin, Empty, Tag, Progress, Skeleton, Tooltip, Card, Typography, List, Badge } from 'antd';
|
|
import {
|
|
InfoCircleOutlined,
|
|
DashboardOutlined,
|
|
RightOutlined,
|
|
} from '@ant-design/icons';
|
|
import { Column, Pie, Funnel, Line } from '@ant-design/charts';
|
|
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboardTypes';
|
|
import type { ActionQueryDef } from '../../api/plugins';
|
|
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: '#60a5fa', 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)}
|
|
railColor="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 || '#2563eb';
|
|
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} />;
|
|
case 'stat_cards': return <StatCardsWidget widgetData={widgetData} />;
|
|
case 'action_list': return <ActionListWidget widgetData={widgetData} />;
|
|
case 'funnel': return <FunnelStageWidget widgetData={widgetData} />;
|
|
case 'card_list': return <CardListWidget widgetData={widgetData} />;
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
// ── Manifest Widget 渲染器 ──
|
|
|
|
/** stat_cards — 多个统计卡片 */
|
|
function StatCardsWidget({ widgetData }: { widgetData: WidgetData }) {
|
|
const { statCards, widget } = widgetData;
|
|
if (!statCards || statCards.length === 0) return <ChartEmpty />;
|
|
return (
|
|
<Card size="small" title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP.stat_cards} {widget.label || widget.title}</span>} className="erp-fade-in">
|
|
<Row gutter={[12, 12]}>
|
|
{statCards.map((sc, i) => (
|
|
<Col xs={12} sm={6} key={`${sc.card.entity}-${sc.card.label}-${i}`}>
|
|
<div style={{
|
|
background: `${sc.card.color || '#2563eb'}10`,
|
|
borderRadius: 8,
|
|
padding: '12px 16px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
}}>
|
|
<div style={{
|
|
width: 36, height: 36, borderRadius: 8,
|
|
background: `${sc.card.color || '#2563eb'}20`,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
color: sc.card.color || '#2563eb', fontSize: 18,
|
|
}}>
|
|
<DashboardOutlined />
|
|
</div>
|
|
<div>
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{sc.card.label}</Typography.Text>
|
|
<div style={{ fontSize: 20, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
|
{sc.value.toLocaleString()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Col>
|
|
))}
|
|
</Row>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
/** action_list — 待办列表 */
|
|
function ActionListWidget({ widgetData }: { widgetData: WidgetData }) {
|
|
const { actionItems, widget } = widgetData;
|
|
if (!actionItems) return <ChartEmpty />;
|
|
const allItems = actionItems.flatMap((ai) =>
|
|
ai.records.map((r) => ({ ...r, _query: ai.query })),
|
|
);
|
|
const maxItems = widget.max_items ?? 10;
|
|
const displayItems = allItems.slice(0, maxItems);
|
|
return (
|
|
<Card size="small" title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP.action_list} {widget.label || widget.title}</span>} className="erp-fade-in">
|
|
{displayItems.length > 0 ? (
|
|
<List
|
|
size="small"
|
|
dataSource={displayItems}
|
|
renderItem={(item) => {
|
|
const q = item._query as ActionQueryDef;
|
|
const title = String(item.data?.[q.label_field] ?? '-');
|
|
const subtitle = q.subtitle_field ? String(item.data?.[q.subtitle_field] ?? '') : '';
|
|
return (
|
|
<List.Item style={{ padding: '8px 0', cursor: 'pointer' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
|
|
<Badge color="blue" />
|
|
<div style={{ flex: 1, overflow: 'hidden' }}>
|
|
<div style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</div>
|
|
{subtitle && <Typography.Text type="secondary" style={{ fontSize: 12 }}>{subtitle}</Typography.Text>}
|
|
</div>
|
|
<RightOutlined style={{ fontSize: 12, color: 'var(--erp-text-quaternary)' }} />
|
|
</div>
|
|
</List.Item>
|
|
);
|
|
}}
|
|
/>
|
|
) : (
|
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待办" />
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
/** funnel — 阶段漏斗 */
|
|
function FunnelStageWidget({ widgetData }: { widgetData: WidgetData }) {
|
|
const { data, widget } = widgetData;
|
|
const chartData = (widget.lane_order ?? [])
|
|
.map((key) => {
|
|
const found = data.find((d) => d.key === key);
|
|
return { key, count: found?.count ?? 0 };
|
|
})
|
|
.filter((d) => d.count > 0);
|
|
return (
|
|
<WidgetCardShell title={widget.label || widget.title} widgetType="funnel_chart">
|
|
{chartData.length > 0 ? (
|
|
<Funnel data={chartData} xField="key" yField="count" legend={{ position: 'bottom' as const }} />
|
|
) : <ChartEmpty />}
|
|
</WidgetCardShell>
|
|
);
|
|
}
|
|
|
|
/** card_list — 卡片列表 */
|
|
function CardListWidget({ widgetData }: { widgetData: WidgetData }) {
|
|
const { records, widget } = widgetData;
|
|
const maxItems = widget.max_items ?? 10;
|
|
const displayRecords = (records ?? []).slice(0, maxItems);
|
|
return (
|
|
<Card size="small" title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP.card_list} {widget.label || widget.title}</span>} className="erp-fade-in">
|
|
{displayRecords.length > 0 ? (
|
|
<List
|
|
size="small"
|
|
dataSource={displayRecords}
|
|
renderItem={(item) => {
|
|
const title = String(item.data?.[widget.title_field ?? 'name'] ?? '-');
|
|
const subtitle = widget.subtitle_field ? String(item.data?.[widget.subtitle_field] ?? '') : '';
|
|
const tagValues = (widget.tags ?? []).map((t) => String(item.data?.[t] ?? '')).filter(Boolean);
|
|
return (
|
|
<List.Item style={{ padding: '8px 0' }}>
|
|
<div style={{ width: '100%' }}>
|
|
<div style={{ fontSize: 13, fontWeight: 500 }}>{title}</div>
|
|
{subtitle && <Typography.Text type="secondary" style={{ fontSize: 12 }}>{subtitle}</Typography.Text>}
|
|
{tagValues.length > 0 && (
|
|
<div style={{ marginTop: 4 }}>
|
|
{tagValues.map((tv, i) => <Tag key={i} style={{ fontSize: 11 }}>{tv}</Tag>)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</List.Item>
|
|
);
|
|
}}
|
|
/>
|
|
) : (
|
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|