From 40b37cc77686268a253511bcd35e2a03a0190e16 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 20 Apr 2026 09:35:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin,freelance,itops,web):=20P5-P6=20das?= =?UTF-8?q?hboard=20widgets=20=E5=B9=B3=E5=8F=B0=E6=89=A9=E5=B1=95=20+=20?= =?UTF-8?q?=E4=BB=AA=E8=A1=A8=E7=9B=98=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (运维概览/紧急待办) --- apps/web/src/api/plugins.ts | 41 +++- apps/web/src/pages/PluginDashboardPage.tsx | 63 ++++- .../src/pages/dashboard/DashboardWidgets.tsx | 144 ++++++++++- .../pages/dashboard/dashboardConstants.tsx | 4 + .../web/src/pages/dashboard/dashboardTypes.ts | 7 +- crates/erp-plugin-freelance/plugin.toml | 43 ++++ crates/erp-plugin-itops/plugin.toml | 26 ++ crates/erp-plugin/src/manifest.rs | 225 ++++++++++++++++++ 8 files changed, 547 insertions(+), 6 deletions(-) diff --git a/apps/web/src/api/plugins.ts b/apps/web/src/api/plugins.ts index 652c830..8450082 100644 --- a/apps/web/src/api/plugins.ts +++ b/apps/web/src/api/plugins.ts @@ -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 = diff --git a/apps/web/src/pages/PluginDashboardPage.tsx b/apps/web/src/pages/PluginDashboardPage.tsx index 58692e9..c776a7f 100644 --- a/apps/web/src/pages/PluginDashboardPage.tsx +++ b/apps/web/src/pages/PluginDashboardPage.tsx @@ -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 ( diff --git a/apps/web/src/pages/dashboard/DashboardWidgets.tsx b/apps/web/src/pages/dashboard/DashboardWidgets.tsx index ef6894d..51cb67f 100644 --- a/apps/web/src/pages/dashboard/DashboardWidgets.tsx +++ b/apps/web/src/pages/dashboard/DashboardWidgets.tsx @@ -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 ; case 'funnel_chart': return ; case 'line_chart': return ; + case 'stat_cards': return ; + case 'action_list': return ; + case 'funnel': return ; + case 'card_list': return ; default: return null; } } + +// ── Manifest Widget 渲染器 ── + +/** stat_cards — 多个统计卡片 */ +function StatCardsWidget({ widgetData }: { widgetData: WidgetData }) { + const { statCards, widget } = widgetData; + if (!statCards || statCards.length === 0) return ; + return ( + {WIDGET_ICON_MAP.stat_cards} {widget.label || widget.title}} className="erp-fade-in"> + + {statCards.map((sc, i) => ( + +
+
+ +
+
+ {sc.card.label} +
+ {sc.value.toLocaleString()} +
+
+
+ + ))} +
+
+ ); +} + +/** action_list — 待办列表 */ +function ActionListWidget({ widgetData }: { widgetData: WidgetData }) { + const { actionItems, widget } = widgetData; + if (!actionItems) return ; + 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 ( + {WIDGET_ICON_MAP.action_list} {widget.label || widget.title}} className="erp-fade-in"> + {displayItems.length > 0 ? ( + { + 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 ( + +
+ +
+
{title}
+ {subtitle && {subtitle}} +
+ +
+
+ ); + }} + /> + ) : ( + + )} +
+ ); +} + +/** 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 ( + + {chartData.length > 0 ? ( + + ) : } + + ); +} + +/** card_list — 卡片列表 */ +function CardListWidget({ widgetData }: { widgetData: WidgetData }) { + const { records, widget } = widgetData; + const maxItems = widget.max_items ?? 10; + const displayRecords = (records ?? []).slice(0, maxItems); + return ( + {WIDGET_ICON_MAP.card_list} {widget.label || widget.title}} className="erp-fade-in"> + {displayRecords.length > 0 ? ( + { + 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 ( + +
+
{title}
+ {subtitle && {subtitle}} + {tagValues.length > 0 && ( +
+ {tagValues.map((tv, i) => {tv})} +
+ )} +
+
+ ); + }} + /> + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/pages/dashboard/dashboardConstants.tsx b/apps/web/src/pages/dashboard/dashboardConstants.tsx index ba54cb8..2e2de11 100644 --- a/apps/web/src/pages/dashboard/dashboardConstants.tsx +++ b/apps/web/src/pages/dashboard/dashboardConstants.tsx @@ -82,6 +82,10 @@ export const WIDGET_ICON_MAP: Record = { pie_chart: , funnel_chart: , line_chart: , + stat_cards: , + action_list: , + funnel: , + card_list: , }; // ── 延迟类名工具 ── diff --git a/apps/web/src/pages/dashboard/dashboardTypes.ts b/apps/web/src/pages/dashboard/dashboardTypes.ts index f29d804..25fdb81 100644 --- a/apps/web/src/pages/dashboard/dashboardTypes.ts +++ b/apps/web/src/pages/dashboard/dashboardTypes.ts @@ -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[] }[]; } diff --git a/crates/erp-plugin-freelance/plugin.toml b/crates/erp-plugin-freelance/plugin.toml index 1f5e44e..d4bef31 100644 --- a/crates/erp-plugin-freelance/plugin.toml +++ b/crates/erp-plugin-freelance/plugin.toml @@ -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" diff --git a/crates/erp-plugin-itops/plugin.toml b/crates/erp-plugin-itops/plugin.toml index cffc9f3..0956c6b 100644 --- a/crates/erp-plugin-itops/plugin.toml +++ b/crates/erp-plugin-itops/plugin.toml @@ -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" diff --git a/crates/erp-plugin/src/manifest.rs b/crates/erp-plugin/src/manifest.rs index 62b060f..c16843a 100644 --- a/crates/erp-plugin/src/manifest.rs +++ b/crates/erp-plugin/src/manifest.rs @@ -284,6 +284,8 @@ pub enum PluginPageType { label: String, #[serde(default)] icon: Option, + #[serde(default)] + widgets: Vec, }, #[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, + }, + #[serde(rename = "action_list")] + ActionList { + label: String, + #[serde(default)] + max_items: Option, + queries: Vec, + }, + #[serde(rename = "funnel")] + Funnel { + label: String, + entity: String, + lane_field: String, + #[serde(default)] + value_field: Option, + lane_order: Vec, + }, + #[serde(rename = "card_list")] + CardList { + label: String, + entity: String, + #[serde(default)] + filter: Option, + #[serde(default)] + max_items: Option, + title_field: String, + #[serde(default)] + subtitle_field: Option, + #[serde(default)] + tags: Vec, + }, +} + +/// 统计卡片 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct StatCard { + pub entity: String, + #[serde(default)] + pub aggregate: Option, + #[serde(default)] + pub field: Option, + #[serde(default)] + pub filter: Option, + pub label: String, + #[serde(default)] + pub icon: Option, + #[serde(default)] + pub color: Option, +} + +/// 待办行动查询 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ActionQuery { + pub entity: String, + #[serde(default)] + pub filter: Option, + #[serde(default)] + pub sort: Option, + pub label_field: String, + #[serde(default)] + pub subtitle_field: Option, + pub action: String, + #[serde(default)] + pub icon: Option, +} + /// 插件页面区段(用于 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"), + } + } }