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:
iven
2026-04-20 09:35:27 +08:00
parent 301178067c
commit 40b37cc776
8 changed files with 547 additions and 6 deletions

View File

@@ -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 =

View File

@@ -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}>

View File

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

View File

@@ -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 />,
};
// ── 延迟类名工具 ──

View File

@@ -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[] }[];
}

View File

@@ -1079,6 +1079,49 @@ type = "dashboard"
label = "工作台"
icon = "DashboardOutlined"
# ── 财务概览卡片 ──
[[ui.pages.widgets]]
type = "stat_cards"
label = "财务概览"
cards = [
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "type == 'payment' && status != 'overdue'", label = "本月收入", icon = "rise", color = "green" },
{ entity = "expense", aggregate = "sum", field = "amount", label = "本月支出", icon = "fall", color = "red" },
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "status == 'overdue' || status == 'pending'", label = "应收总额", icon = "dollar", color = "orange" },
{ entity = "invoice", aggregate = "count", filter = "status == 'overdue'", label = "逾期笔数", icon = "warning", color = "red" }
]
# ── 紧急待办 ──
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
queries = [
{ entity = "invoice", filter = "status == 'overdue'", label_field = "invoice_number", subtitle_field = "amount", action = "查看", icon = "warning" },
{ entity = "task", filter = "status != 'done' && status != 'cancelled'", sort = "due_date asc", label_field = "title", subtitle_field = "due_date", action = "处理", icon = "clock" },
{ entity = "contract", filter = "status == 'active'", sort = "end_date asc", label_field = "title", subtitle_field = "end_date", action = "续约", icon = "file-text" },
{ entity = "opportunity", filter = "next_follow_up <= today", label_field = "title", subtitle_field = "next_follow_up", action = "跟进", icon = "phone" }
]
# ── 商机漏斗 ──
[[ui.pages.widgets]]
type = "funnel"
label = "商机漏斗"
entity = "opportunity"
lane_field = "stage"
value_field = "estimated_amount"
lane_order = ["visit", "requirement", "quote", "negotiation", "won", "lost"]
# ── 活跃项目卡片 ──
[[ui.pages.widgets]]
type = "card_list"
label = "活跃项目"
entity = "project"
filter = "status == 'in_progress'"
max_items = 4
title_field = "name"
subtitle_field = "contract_amount"
tags = ["business_type", "status"]
# 页面 2客户管理列表 + 详情 + 商机看板)
[[ui.pages]]
type = "tabs"

View File

@@ -546,6 +546,32 @@ format = "pdf"
# ── 页面设计 ──
# 页面 0运维概览仪表盘
[[ui.pages]]
type = "dashboard"
label = "运维概览"
icon = "DashboardOutlined"
[[ui.pages.widgets]]
type = "stat_cards"
label = "运维概览"
cards = [
{ entity = "service_contract", aggregate = "count", filter = "status == 'active'", label = "活跃合同", icon = "file-text", color = "blue" },
{ entity = "ticket", aggregate = "count", filter = "status == 'open' || status == 'in_progress'", label = "待处理工单", icon = "tool", color = "orange" },
{ entity = "ticket", aggregate = "count", filter = "status == 'resolved'", label = "已解决工单", icon = "check-circle", color = "green" },
{ entity = "check_plan", aggregate = "count", filter = "status == 'active'", label = "活跃巡检", icon = "schedule", color = "blue" }
]
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
queries = [
{ entity = "ticket", filter = "status == 'open'", sort = "priority asc", label_field = "title", subtitle_field = "type", action = "处理", icon = "warning" },
{ entity = "service_contract", filter = "status == 'active'", sort = "end_date asc", label_field = "name", subtitle_field = "end_date", action = "续约", icon = "file-text" },
{ entity = "check_plan", filter = "status == 'active'", sort = "next_check_date asc", label_field = "name", subtitle_field = "next_check_date", action = "巡检", icon = "schedule" }
]
# 页面 1合同管理 + 详情
[[ui.pages]]
type = "crud"

View File

@@ -284,6 +284,8 @@ pub enum PluginPageType {
label: String,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
widgets: Vec<PluginWidget>,
},
#[serde(rename = "kanban")]
Kanban {
@@ -304,6 +306,80 @@ pub enum PluginPageType {
},
}
/// Dashboard Widget 类型
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PluginWidget {
#[serde(rename = "stat_cards")]
StatCards {
label: String,
cards: Vec<StatCard>,
},
#[serde(rename = "action_list")]
ActionList {
label: String,
#[serde(default)]
max_items: Option<u32>,
queries: Vec<ActionQuery>,
},
#[serde(rename = "funnel")]
Funnel {
label: String,
entity: String,
lane_field: String,
#[serde(default)]
value_field: Option<String>,
lane_order: Vec<String>,
},
#[serde(rename = "card_list")]
CardList {
label: String,
entity: String,
#[serde(default)]
filter: Option<String>,
#[serde(default)]
max_items: Option<u32>,
title_field: String,
#[serde(default)]
subtitle_field: Option<String>,
#[serde(default)]
tags: Vec<String>,
},
}
/// 统计卡片
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct StatCard {
pub entity: String,
#[serde(default)]
pub aggregate: Option<String>,
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub filter: Option<String>,
pub label: String,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub color: Option<String>,
}
/// 待办行动查询
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ActionQuery {
pub entity: String,
#[serde(default)]
pub filter: Option<String>,
#[serde(default)]
pub sort: Option<String>,
pub label_field: String,
#[serde(default)]
pub subtitle_field: Option<String>,
pub action: String,
#[serde(default)]
pub icon: Option<String>,
}
/// 插件页面区段(用于 detail 页面类型)
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
@@ -1553,4 +1629,153 @@ name = "管理发票"
assert_eq!(entities[0].importable, Some(true));
assert_eq!(entities[0].exportable, Some(true));
}
#[test]
fn parse_dashboard_with_widgets() {
let toml = r##"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "invoice"
display_name = "发票"
[[schema.entities.fields]]
name = "status"
field_type = "string"
display_name = "状态"
[[schema.entities.fields]]
name = "amount"
field_type = "decimal"
display_name = "金额"
[ui]
[[ui.pages]]
type = "dashboard"
label = "工作台"
icon = "DashboardOutlined"
[[ui.pages.widgets]]
type = "stat_cards"
label = "财务概览"
[[ui.pages.widgets.cards]]
entity = "invoice"
aggregate = "count"
label = "总发票"
icon = "FileTextOutlined"
color = "#1890ff"
[[ui.pages.widgets.cards]]
entity = "invoice"
aggregate = "sum"
field = "amount"
filter = "status == 'pending'"
label = "待收金额"
icon = "DollarOutlined"
color = "#faad14"
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
[[ui.pages.widgets.queries]]
entity = "invoice"
filter = "status == 'overdue'"
sort = "due_date asc"
label_field = "invoice_number"
subtitle_field = "amount"
action = "open_invoice"
icon = "warning"
[[ui.pages.widgets]]
type = "funnel"
label = "商机漏斗"
entity = "invoice"
lane_field = "status"
value_field = "amount"
lane_order = ["pending", "issued", "paid"]
[[ui.pages.widgets]]
type = "card_list"
label = "活跃项目"
entity = "invoice"
filter = "status == 'active'"
max_items = 10
title_field = "invoice_number"
subtitle_field = "amount"
tags = ["status"]
"##;
let manifest = parse_manifest(toml).unwrap();
let ui = manifest.ui.unwrap();
assert_eq!(ui.pages.len(), 1);
match &ui.pages[0] {
PluginPageType::Dashboard {
label, icon, widgets,
} => {
assert_eq!(label, "工作台");
assert_eq!(icon.as_deref(), Some("DashboardOutlined"));
assert_eq!(widgets.len(), 4);
// stat_cards
match &widgets[0] {
PluginWidget::StatCards { label, cards } => {
assert_eq!(label, "财务概览");
assert_eq!(cards.len(), 2);
assert_eq!(cards[0].entity, "invoice");
assert_eq!(cards[0].aggregate.as_deref(), Some("count"));
assert_eq!(cards[1].aggregate.as_deref(), Some("sum"));
assert_eq!(cards[1].filter.as_deref(), Some("status == 'pending'"));
}
_ => panic!("Expected StatCards"),
}
// action_list
match &widgets[1] {
PluginWidget::ActionList {
label, max_items, queries,
} => {
assert_eq!(label, "紧急待办");
assert_eq!(*max_items, Some(5));
assert_eq!(queries.len(), 1);
assert_eq!(queries[0].entity, "invoice");
assert_eq!(queries[0].action, "open_invoice");
}
_ => panic!("Expected ActionList"),
}
// funnel
match &widgets[2] {
PluginWidget::Funnel {
label, entity, lane_field, value_field, lane_order,
} => {
assert_eq!(label, "商机漏斗");
assert_eq!(entity, "invoice");
assert_eq!(lane_field, "status");
assert_eq!(value_field.as_deref(), Some("amount"));
assert_eq!(lane_order, &["pending", "issued", "paid"]);
}
_ => panic!("Expected Funnel"),
}
// card_list
match &widgets[3] {
PluginWidget::CardList {
label, entity, title_field, ..
} => {
assert_eq!(label, "活跃项目");
assert_eq!(entity, "invoice");
assert_eq!(title_field, "invoice_number");
}
_ => panic!("Expected CardList"),
}
}
_ => panic!("Expected Dashboard page type"),
}
}
}