- dashboardConstants.ts → .tsx (包含 JSX 不能在 .ts 中) - dashboardTypes.ts: AggregateItem 从 pluginData 导入而非 plugins - PluginDashboardPage.tsx: 移除未使用的 Spin 导入
398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|