P5 平台扩展: - manifest.rs: Dashboard 变体新增 widgets 字段 - manifest.rs: 定义 PluginWidget/StatCard/ActionQuery 类型 - 前端: 扩展 DashboardWidget 类型支持 stat_cards/action_list/funnel/card_list - 前端: 新增 4 个 widget 渲染器 (StatCardsWidget/ActionListWidget/FunnelStageWidget/CardListWidget) - 前端: PluginDashboardPage widget 数据加载支持新类型 P6 仪表盘 widgets: - freelance: 工作台仪表盘 4 个 widgets (财务概览/紧急待办/商机漏斗/活跃项目) - itops: 新增运维概览仪表盘 2 个 widgets (运维概览/紧急待办)
458 lines
16 KiB
TypeScript
458 lines
16 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, listPluginData } 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 { getEntityPalette, getEntityIcon, 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 (let i = 0; i < entities.length; i++) {
|
|
const entity = entities[i];
|
|
if (abortController.signal.aborted) return;
|
|
const palette = getEntityPalette(entity.name, i);
|
|
const icon = getEntityIcon(entity.name);
|
|
try {
|
|
const count = await countPluginData(pluginId!, entity.name);
|
|
if (abortController.signal.aborted) return;
|
|
results.push({
|
|
name: entity.name,
|
|
displayName: entity.display_name || entity.name,
|
|
count,
|
|
icon,
|
|
gradient: palette.gradient,
|
|
iconBg: palette.iconBg,
|
|
});
|
|
} catch {
|
|
results.push({
|
|
name: entity.name,
|
|
displayName: entity.display_name || entity.name,
|
|
count: 0,
|
|
icon,
|
|
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 };
|
|
}
|
|
// stat_cards — 多个统计卡片
|
|
if (widget.type === 'stat_cards' && widget.cards) {
|
|
const cardResults = await Promise.all(
|
|
widget.cards.map(async (card) => {
|
|
try {
|
|
const count = await countPluginData(pluginId!, card.entity, {
|
|
filter: card.filter ? JSON.parse(card.filter) : undefined,
|
|
});
|
|
return { card, value: count };
|
|
} catch {
|
|
return { card, value: 0 };
|
|
}
|
|
}),
|
|
);
|
|
return { widget, data: [], statCards: cardResults };
|
|
}
|
|
// action_list — 待办列表
|
|
if (widget.type === 'action_list' && widget.queries) {
|
|
const actionResults = await Promise.all(
|
|
widget.queries.map(async (query) => {
|
|
try {
|
|
const filterObj = query.filter ? JSON.parse(query.filter) : undefined;
|
|
const sortParts = query.sort?.split(' ') ?? [];
|
|
const result = await listPluginData(pluginId!, query.entity, 1, widget.max_items ?? 10, {
|
|
filter: filterObj,
|
|
sort_by: sortParts[0] || undefined,
|
|
sort_order: (sortParts[1] as 'asc' | 'desc') || undefined,
|
|
});
|
|
return { query, records: result.data };
|
|
} catch {
|
|
return { query, records: [] };
|
|
}
|
|
}),
|
|
);
|
|
return { widget, data: [], actionItems: actionResults };
|
|
}
|
|
// funnel — 阶段漏斗
|
|
if (widget.type === 'funnel' && widget.lane_field) {
|
|
const data = await aggregatePluginData(
|
|
pluginId!,
|
|
widget.entity,
|
|
widget.lane_field,
|
|
);
|
|
return { widget, data };
|
|
}
|
|
// card_list — 卡片列表
|
|
if (widget.type === 'card_list') {
|
|
const filterObj = widget.filter ? JSON.parse(widget.filter) : undefined;
|
|
const result = await listPluginData(pluginId!, widget.entity, 1, widget.max_items ?? 10, {
|
|
filter: filterObj,
|
|
});
|
|
return { widget, data: [], records: result.data };
|
|
}
|
|
// 旧类型图表
|
|
if (widget.dimension_field) {
|
|
const data = await aggregatePluginData(
|
|
pluginId!,
|
|
widget.entity,
|
|
widget.dimension_field,
|
|
);
|
|
return { widget, data };
|
|
}
|
|
// fallback — 仅返回计数
|
|
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(
|
|
() => getEntityPalette(selectedEntity, entities.findIndex((e) => e.name === selectedEntity)),
|
|
[selectedEntity, entities],
|
|
);
|
|
// ── 渲染 ──
|
|
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,
|
|
}}
|
|
>
|
|
{pluginId ? `${pluginId.toUpperCase()} 数据统计` : '数据统计'}
|
|
</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
|
|
: wd.widget.type === 'stat_cards' ? 24
|
|
: wd.widget.type === 'action_list' ? 12
|
|
: wd.widget.type === 'funnel' ? 12
|
|
: wd.widget.type === 'card_list' ? 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>
|
|
);
|
|
}
|