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"),
+ }
+ }
}