Files
hms/apps/web/src/pages/PluginDashboardPage.tsx
iven 40b37cc776 feat(plugin,freelance,itops,web): P5-P6 dashboard widgets 平台扩展 + 仪表盘声明
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 (运维概览/紧急待办)
2026-04-20 09:35:27 +08:00

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