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 (运维概览/紧急待办)
This commit is contained in:
@@ -199,7 +199,8 @@ export type PluginPageSchema =
|
||||
};
|
||||
|
||||
export interface DashboardWidget {
|
||||
type: 'stat_card' | 'bar_chart' | 'pie_chart' | 'funnel_chart' | 'line_chart';
|
||||
type: 'stat_card' | 'bar_chart' | 'pie_chart' | 'funnel_chart' | 'line_chart'
|
||||
| 'stat_cards' | 'action_list' | 'funnel' | 'card_list';
|
||||
entity: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
@@ -207,6 +208,44 @@ export interface DashboardWidget {
|
||||
dimension_field?: string;
|
||||
dimension_order?: string[];
|
||||
metric?: string;
|
||||
// stat_cards
|
||||
cards?: StatCardDef[];
|
||||
// action_list
|
||||
max_items?: number;
|
||||
queries?: ActionQueryDef[];
|
||||
// funnel
|
||||
lane_field?: string;
|
||||
value_field?: string;
|
||||
lane_order?: string[];
|
||||
// card_list
|
||||
filter?: string;
|
||||
title_field?: string;
|
||||
subtitle_field?: string;
|
||||
tags?: string[];
|
||||
label?: string;
|
||||
label_field?: string;
|
||||
action?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface StatCardDef {
|
||||
entity: string;
|
||||
aggregate?: string;
|
||||
field?: string;
|
||||
filter?: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface ActionQueryDef {
|
||||
entity: string;
|
||||
filter?: string;
|
||||
sort?: string;
|
||||
label_field: string;
|
||||
subtitle_field?: string;
|
||||
action: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export type PluginSectionSchema =
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { countPluginData, aggregatePluginData, listPluginData } from '../api/pluginData';
|
||||
import {
|
||||
getPluginSchema,
|
||||
type PluginEntitySchema,
|
||||
@@ -134,10 +134,65 @@ export function PluginDashboardPage() {
|
||||
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!,
|
||||
@@ -146,7 +201,7 @@ export function PluginDashboardPage() {
|
||||
);
|
||||
return { widget, data };
|
||||
}
|
||||
// 没有 dimension_field 时仅返回计数
|
||||
// fallback — 仅返回计数
|
||||
const count = await countPluginData(pluginId!, widget.entity);
|
||||
return { widget, data: [], count };
|
||||
} catch {
|
||||
@@ -313,6 +368,10 @@ export function PluginDashboardPage() {
|
||||
{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}>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Col, Spin, Empty, Tag, Progress, Skeleton, Tooltip, Card, Typography } from 'antd';
|
||||
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 ──
|
||||
@@ -293,6 +295,146 @@ export function WidgetRenderer({ widgetData, isDark }: { widgetData: WidgetData;
|
||||
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 || '#4F46E5'}10`,
|
||||
borderRadius: 8,
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 8,
|
||||
background: `${sc.card.color || '#4F46E5'}20`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: sc.card.color || '#4F46E5', 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ export const WIDGET_ICON_MAP: Record<string, React.ReactNode> = {
|
||||
pie_chart: <PieChartOutlined />,
|
||||
funnel_chart: <FunnelPlotOutlined />,
|
||||
line_chart: <LineChartOutlined />,
|
||||
stat_cards: <DashboardOutlined />,
|
||||
action_list: <AppstoreOutlined />,
|
||||
funnel: <FunnelPlotOutlined />,
|
||||
card_list: <DatabaseOutlined />,
|
||||
};
|
||||
|
||||
// ── 延迟类名工具 ──
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type React from 'react';
|
||||
import type { AggregateItem } from '../../api/pluginData';
|
||||
import type { DashboardWidget } from '../../api/plugins';
|
||||
import type { AggregateItem, PluginDataRecord } from '../../api/pluginData';
|
||||
import type { DashboardWidget, StatCardDef, ActionQueryDef } from '../../api/plugins';
|
||||
|
||||
// ── 类型定义 ──
|
||||
|
||||
@@ -23,4 +23,7 @@ export interface WidgetData {
|
||||
widget: DashboardWidget;
|
||||
data: AggregateItem[];
|
||||
count?: number;
|
||||
records?: PluginDataRecord[];
|
||||
statCards?: { card: StatCardDef; value: number }[];
|
||||
actionItems?: { query: ActionQueryDef; records: PluginDataRecord[] }[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user