Files
erp/apps/web/src/pages/PluginDashboardPage.tsx
iven 9fb73788f7 fix(web): 修复 Dashboard 拆分后遗留问题
- dashboardConstants.ts → .tsx (包含 JSX 不能在 .ts 中)
- dashboardTypes.ts: AggregateItem 从 pluginData 导入而非 plugins
- PluginDashboardPage.tsx: 移除未使用的 Spin 导入
2026-04-17 12:53:35 +08:00

398 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Row, Col, Empty, Select, theme } from 'antd';
import { DashboardOutlined } from '@ant-design/icons';
import { countPluginData, aggregatePluginData } from '../api/pluginData';
import {
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';
// ── 主组件 ──
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 [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)';
// 加载 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;
const entityList = schema.entities || [];
setEntities(entityList);
if (entityList.length > 0) {
setSelectedEntity(entityList[0].name);
}
// 提取 dashboard widgets
const pages = schema.ui?.pages || [];
const dashboardPage = pages.find(
(p): p is PluginPageSchema & { type: 'dashboard'; widgets?: DashboardWidget[] } =>
p.type === 'dashboard',
);
if (dashboardPage?.widgets && dashboardPage.widgets.length > 0) {
setWidgets(dashboardPage.widgets);
}
} catch {
setError('Schema 加载失败,部分功能不可用');
} finally {
if (!abortController.signal.aborted) setSchemaLoading(false);
}
}
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) {
if (abortController.signal.aborted) return;
try {
const count = await countPluginData(pluginId!, entity.name);
if (abortController.signal.aborted) return;
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) {
setEntityStats(results);
}
}
loadAllCounts();
return () => abortController.abort();
}, [pluginId, entities]);
// Widget 数据并行加载
useEffect(() => {
if (!pluginId || widgets.length === 0) return;
const abortController = new AbortController();
async function loadWidgetData() {
setWidgetsLoading(true);
try {
const results = await Promise.all(
widgets.map(async (widget) => {
try {
if (widget.type === 'stat_card') {
const count = await countPluginData(pluginId!, widget.entity);
return { widget, data: [], count };
}
if (widget.dimension_field) {
const data = await aggregatePluginData(
pluginId!,
widget.entity,
widget.dimension_field,
);
return { widget, data };
}
// 没有 dimension_field 时仅返回计数
const count = await countPluginData(pluginId!, widget.entity);
return { widget, data: [], count };
} catch {
return { widget, data: [], count: 0 };
}
}),
);
if (!abortController.signal.aborted) {
setWidgetData(results);
}
} finally {
if (!abortController.signal.aborted) setWidgetsLoading(false);
}
}
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 {
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);
}
return () => abortController.abort();
}, [pluginId, selectedEntity, filterableFields, entityStats]);
useEffect(() => {
const cleanup = loadData();
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 }}>
<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 }}>
{/* 页面标题 */}
<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: 160 }}
options={entities.map((e) => ({
label: e.display_name || e.name,
value: e.name,
}))}
onChange={setSelectedEntity}
aria-label="选择实体类型"
/>
</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)}
/>
))}
</Row>
{/* Widget 图表区域 */}
{widgets.length > 0 && (
<>
<div style={{ marginBottom: 16 }}>
<div className="erp-section-header">
<DashboardOutlined
className="erp-section-icon"
style={{ color: '#4F46E5' }}
/>
<span className="erp-section-title"></span>
</div>
</div>
{widgetsLoading && widgetData.length === 0 ? (
<Row gutter={[16, 16]}>
{widgets.map((_, i) => (
<SkeletonBreakdownCard key={i} index={i} />
))}
</Row>
) : (
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{widgetData.map((wd) => {
const colSpan = wd.widget.type === 'stat_card' ? 6
: wd.widget.type === 'pie_chart' || wd.widget.type === 'funnel_chart' ? 12
: 12;
return (
<Col key={`${wd.widget.type}-${wd.widget.entity}-${wd.widget.title}`} xs={24} sm={colSpan}>
<WidgetRenderer widgetData={wd} isDark={isDark} />
</Col>
);
})}
</Row>
)}
</>
)}
{/* 分组统计区域 */}
<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>
{loading && breakdowns.length === 0 ? (
<Row gutter={[16, 16]}>
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonBreakdownCard key={i} index={i} />
))}
</Row>
) : breakdowns.length > 0 ? (
<Row gutter={[16, 16]}>
{breakdowns.map((bd, i) => (
<BreakdownCard
key={bd.fieldName}
breakdown={bd}
totalCount={currentTotal}
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>
);
}