Jelajahi Sumber

feat(aidop): add diagnosis improvement loop

Implement the dynamic S1 dashboard, smart diagnosis drilldown, and improvement approval loop so diagnosis findings can flow into tracked actions and verification. Bump frontend to 2.4.104 and backend to 1.0.71.

Made-with: Cursor
skygu 6 bulan lalu
induk
melakukan
9516165e52
29 mengubah file dengan 6243 tambahan dan 1322 penghapusan
  1. 1 1
      Web/package.json
  2. 1 0
      Web/src/utils/aidopMenuDisplay.ts
  3. 326 1
      Web/src/views/aidop/api/kanbanData.ts
  4. 940 8
      Web/src/views/aidop/diagnosis/index.vue
  5. 1061 0
      Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue
  6. 584 0
      Web/src/views/aidop/kanban/dashboardConfig.vue
  7. 6 4
      Web/src/views/aidop/kanban/kpiMaster.vue
  8. 20 1276
      Web/src/views/aidop/kanban/s1.vue
  9. 1080 0
      doc/plan/动态运营详情看板与指标改善闭环方案.md
  10. 5 1
      server/Admin.NET.Application/Configuration/Database.json
  11. 20 0
      server/Admin.NET.Core/Option/DbConnectionOptions.cs
  12. 90 10
      server/Admin.NET.Core/SqlSugar/SqlSugarSetup.cs
  13. 3 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  14. 534 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AdoSmartOpsImprovementPlanController.cs
  15. 53 8
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AdoSmartOpsKpiMasterController.cs
  16. 596 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.DashboardPage.cs
  17. 200 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Diagnosis.cs
  18. 15 4
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Generic.cs
  19. 14 3
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.S4.cs
  20. 37 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsDashboardPageConfig.cs
  21. 58 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsDashboardWidget.cs
  22. 84 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsImprovementPlan.cs
  23. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiMaster.cs
  24. 170 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopImprovementApprovalSeed.cs
  25. 184 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopKpiMasterSeed.cs
  26. 17 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopTenantMigration.cs
  27. 72 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/FormulaExpr/FormulaRuntimeEvaluator.cs
  28. 57 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/SmartOpsImprovementBizHandler.cs
  29. 14 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.103",
+	"version": "2.4.104",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 1 - 0
Web/src/utils/aidopMenuDisplay.ts

@@ -47,6 +47,7 @@ const PRODUCTION_AUX_ROUTES: Array<{ path: string; title: string; component: str
 const SMART_OPS_CHILDREN: Array<{ path: string; title: string; component: string; name: string }> = [
 	{ path: '/aidop/smart-ops/grid', title: '九宫格智慧运营看板', component: '/dashboard/home', name: 'aidopSmartOpsGrid' },
 	{ path: '/aidop/smart-ops/modeling', title: '运营指标建模', component: '/aidop/kanban/s0', name: 'aidopSmartOpsModeling' },
+	{ path: '/aidop/smart-ops/dashboard-config', title: '详情看板配置', component: '/aidop/kanban/dashboardConfig', name: 'aidopSmartOpsDashboardConfig' },
 	{ path: '/aidop/smart-ops/s1', title: 'S1产销协同看板', component: '/aidop/kanban/s1', name: 'aidopSmartOpsS1' },
 	{ path: '/aidop/smart-ops/s2', title: 'S2制造协同看板', component: '/aidop/kanban/s2', name: 'aidopSmartOpsS2' },
 	{ path: '/aidop/smart-ops/s3', title: 'S3供应协同看板', component: '/aidop/kanban/s3', name: 'aidopSmartOpsS3' },

+ 326 - 1
Web/src/views/aidop/api/kanbanData.ts

@@ -28,6 +28,206 @@ export interface ModuleKpiRow {
 	statDate: string;
 }
 
+export interface DynamicDashboardTrendPoint {
+	date: string;
+	value: number | null;
+}
+
+export interface DynamicDashboardMetric {
+	id?: number;
+	metricCode: string;
+	metricName: string;
+	metricLevel: number;
+	parentId?: number | null;
+	unit?: string | null;
+	direction?: string | null;
+	formula?: string | null;
+	formulaExpr?: string | null;
+	formulaPreview?: string | null;
+	calcRule?: string | null;
+	dataSource?: string | null;
+	department?: string | null;
+	currentValue: number | null;
+	targetValue: number | null;
+	statusColor?: string | null;
+	isBlink?: boolean;
+	trendFlag?: string | null;
+	gapValue?: number | null;
+	gapLabel?: string | null;
+	trend: DynamicDashboardTrendPoint[];
+}
+
+export interface DynamicDashboardWidget {
+	sectionCode: string;
+	tabMetricCode?: string | null;
+	widgetCode: string;
+	widgetType: 'metric_card' | 'progress_card' | 'gauge' | 'trend_line' | 'metric_tree' | 'metric_table' | string;
+	title?: string | null;
+	metricLevel: number;
+	span: number;
+	sortNo: number;
+	style?: Record<string, unknown>;
+	metrics: DynamicDashboardMetric[];
+}
+
+export interface DynamicDashboardSection {
+	sectionCode: string;
+	title: string;
+	layout: string;
+	widgets: DynamicDashboardWidget[];
+}
+
+export interface DynamicDashboardPagePayload {
+	moduleCode: string;
+	title: string;
+	subtitle?: string;
+	layoutMode: string;
+	navigationMode?: 'tabs_by_l1' | 'l1_cards' | 'single_page' | 'auto' | string;
+	tabSource?: string;
+	defaultTabMetricCode?: string | null;
+	tabs?: Array<{ metricCode: string; title: string; sortNo: number }>;
+	dataSource?: {
+		metricDefinition?: string;
+		valueTables?: string[];
+		formulaStatus?: string;
+	};
+	metrics?: DynamicDashboardMetric[];
+	sections: DynamicDashboardSection[];
+}
+
+export interface DynamicDashboardWidgetSave {
+	SectionCode?: string | null;
+	TabMetricCode?: string | null;
+	WidgetCode?: string | null;
+	WidgetType?: string | null;
+	Title?: string | null;
+	MetricLevel: number;
+	MetricCodes: string[];
+	Span: number;
+	SortNo: number;
+	StyleJson?: string | null;
+}
+
+export interface DynamicDashboardPageSave {
+	NavigationMode?: string;
+	TabSource?: string;
+	DefaultTabMetricCode?: string | null;
+	Widgets: DynamicDashboardWidgetSave[];
+}
+
+export interface SmartDiagnosisNode {
+	key: string;
+	name: string;
+	plan?: string;
+	actual?: string;
+	status: 'green' | 'yellow' | 'red' | string;
+	dept?: string;
+}
+
+export interface SmartDiagnosisStage {
+	key: string;
+	name: string;
+	flowCaption?: string;
+	dept?: string;
+	kpi?: string;
+	actualDays?: string;
+	actualRate?: string;
+	status: 'green' | 'yellow' | 'red' | string;
+	callout?: string;
+	calloutTone?: string;
+	sortNo?: number;
+	tier1?: SmartDiagnosisNode[];
+	tier2?: Record<string, SmartDiagnosisNode[]>;
+}
+
+export interface SmartDiagnosisPayload {
+	moduleCode: string;
+	metricCode?: string | null;
+	metricName?: string | null;
+	title?: string;
+	subtitle?: string;
+	root?: {
+		metricCode: string;
+		metricName: string;
+		currentValue: number | null;
+		targetValue: number | null;
+		unit?: string;
+		status?: string;
+		gapLabel?: string | null;
+	};
+	pmSummary?: { node?: string; leadDelta?: string };
+	stages: SmartDiagnosisStage[];
+}
+
+export interface SmartOpsImprovementActionItem {
+	content: string;
+	owner?: string;
+	dueDate?: string;
+	status?: 'pending' | 'doing' | 'completed' | 'overdue' | string;
+	completedAt?: string;
+}
+
+export interface SmartOpsImprovementPlan {
+	id: number;
+	planNo: string;
+	moduleCode: string;
+	metricCode?: string | null;
+	problemLevel: number;
+	problemMetricCode?: string | null;
+	problemName: string;
+	problemDept?: string | null;
+	targetValue?: string | null;
+	actualValue?: string | null;
+	gapLabel?: string | null;
+	rootCause?: string | null;
+	actionItems?: SmartOpsImprovementActionItem[];
+	ownerUserId?: number | null;
+	dueDate?: string | null;
+	status: string;
+	flowInstanceId?: number | null;
+	verifyResult?: string | null;
+	verifyValue?: string | null;
+	verifyRemark?: string | null;
+}
+
+export interface SmartOpsVerificationActionNode {
+	index: number;
+	content: string;
+	owner?: string | null;
+	dueDate?: string | null;
+	status: string;
+	statusLabel: string;
+	completedAt?: string | null;
+}
+
+export interface SmartOpsVerificationTimelineDay {
+	date: string;
+	isStart: boolean;
+	isEnd: boolean;
+	actions: SmartOpsVerificationActionNode[];
+}
+
+export interface SmartOpsVerificationTrendPoint {
+	date: string;
+	value: number | null;
+	target: number | null;
+}
+
+export interface SmartOpsVerificationDashboard {
+	plan: SmartOpsImprovementPlan;
+	metric: {
+		metricCode?: string | null;
+		metricName?: string | null;
+		unit?: string | null;
+		targetValue?: number | null;
+		level?: number;
+	};
+	startDate: string;
+	endDate: string;
+	timeline: SmartOpsVerificationTimelineDay[];
+	trend: SmartOpsVerificationTrendPoint[];
+}
+
 export interface ModuleDetailPayload {
 	moduleCode: string;
 	l2: ModuleKpiRow[];
@@ -63,6 +263,131 @@ export async function fetchSmartOpsFilterOptions(
 	}
 }
 
+export async function fetchDynamicDashboardPage(
+	moduleCode: string,
+	factoryId = 1,
+	extra: KanbanExtraQuery = {}
+): Promise<DynamicDashboardPagePayload | null> {
+	try {
+		const res = await service.get(`/api/AidopKanban/dashboard-page/${encodeURIComponent(moduleCode)}`, {
+			params: { factoryId, ...extra },
+			headers: { 'X-Silent-Error': '1' },
+		});
+		const d = res.data ?? {};
+		return {
+			moduleCode: d.moduleCode ?? moduleCode,
+			title: d.title ?? `${moduleCode} 运营看板`,
+			subtitle: d.subtitle ?? '动态运营指标看板',
+			layoutMode: d.layoutMode ?? 'section_grid',
+			navigationMode: d.navigationMode ?? 'single_page',
+			tabSource: d.tabSource ?? 'l1_metrics',
+			defaultTabMetricCode: d.defaultTabMetricCode ?? null,
+			tabs: Array.isArray(d.tabs) ? d.tabs : [],
+			dataSource: d.dataSource ?? {},
+			sections: Array.isArray(d.sections) ? d.sections : [],
+		};
+	} catch {
+		return null;
+	}
+}
+
+export async function saveDynamicDashboardPage(
+	moduleCode: string,
+	body: DynamicDashboardPageSave,
+	factoryId = 1
+): Promise<{ ok: boolean; count?: number; message?: string }> {
+	try {
+		const res = await service.put(`/api/AidopKanban/dashboard-page/${encodeURIComponent(moduleCode)}`, body, {
+			params: { factoryId },
+		});
+		const d = res.data as { ok?: boolean; count?: number } | undefined;
+		return { ok: Boolean(d?.ok), count: d?.count };
+	} catch (e: any) {
+		const msg =
+			e?.response?.data?.message ??
+			(typeof e?.response?.data === 'string' ? e.response.data : undefined) ??
+			e?.message;
+		return { ok: false, message: typeof msg === 'string' ? msg : '保存失败' };
+	}
+}
+
+export async function fetchSmartDiagnosis(
+	moduleCode: string,
+	metricCode?: string | null,
+	factoryId = 1,
+	extra: KanbanExtraQuery = {}
+): Promise<SmartDiagnosisPayload | null> {
+	try {
+		const res = await service.get(`/api/AidopKanban/smart-diagnosis/${encodeURIComponent(moduleCode)}`, {
+			params: { factoryId, metricCode, ...extra },
+			headers: { 'X-Silent-Error': '1' },
+		});
+		const d = res.data ?? {};
+		return {
+			moduleCode: d.moduleCode ?? moduleCode,
+			metricCode: d.metricCode ?? metricCode ?? null,
+			metricName: d.metricName ?? null,
+			title: d.title,
+			subtitle: d.subtitle,
+			root: d.root,
+			pmSummary: d.pmSummary,
+			stages: Array.isArray(d.stages) ? d.stages : [],
+		};
+	} catch {
+		return null;
+	}
+}
+
+export async function createImprovementPlanFromDiagnosis(body: Partial<SmartOpsImprovementPlan> & { factoryId?: number }) {
+	const res = await service.post('/api/AdoSmartOpsImprovementPlan/createFromDiagnosis', body);
+	return (res.data ?? null) as SmartOpsImprovementPlan | null;
+}
+
+export async function fetchImprovementPlanDetail(id: number) {
+	const res = await service.get('/api/AdoSmartOpsImprovementPlan/detail', { params: { id } });
+	return (res.data ?? null) as SmartOpsImprovementPlan | null;
+}
+
+export async function fetchImprovementVerificationDashboard(id: number) {
+	const res = await service.get('/api/AdoSmartOpsImprovementPlan/verificationDashboard', { params: { id } });
+	return (res.data ?? null) as SmartOpsVerificationDashboard | null;
+}
+
+export async function updateImprovementActionItem(body: {
+	id: number;
+	index: number;
+	status: 'pending' | 'doing' | 'completed' | string;
+	completedAt?: string | null;
+}) {
+	const res = await service.post('/api/AdoSmartOpsImprovementPlan/updateActionItem', body);
+	return (res.data ?? null) as SmartOpsImprovementPlan | null;
+}
+
+export async function submitImprovementPlanApproval(id: number, comment?: string) {
+	const res = await service.post('/api/AdoSmartOpsImprovementPlan/submitApproval', { id, comment });
+	return res.data as { instanceId?: number; plan?: SmartOpsImprovementPlan };
+}
+
+export async function startImprovementPlanExecution(id: number) {
+	const res = await service.post('/api/AdoSmartOpsImprovementPlan/startExecution', { id });
+	return (res.data ?? null) as SmartOpsImprovementPlan | null;
+}
+
+export async function submitImprovementPlanVerification(body: {
+	id: number;
+	verifyResult?: string;
+	verifyValue?: string;
+	verifyRemark?: string;
+}) {
+	const res = await service.post('/api/AdoSmartOpsImprovementPlan/submitVerification', body);
+	return (res.data ?? null) as SmartOpsImprovementPlan | null;
+}
+
+export async function closeImprovementPlan(id: number) {
+	const res = await service.post('/api/AdoSmartOpsImprovementPlan/close', { id });
+	return (res.data ?? null) as SmartOpsImprovementPlan | null;
+}
+
 export async function fetchHomeL1(
 	factoryId = 1,
 	extra: KanbanExtraQuery = {}
@@ -290,7 +615,7 @@ export async function fetchHomeGrid(
 	}
 }
 
-/** 通用详情 KPI(L2/L3),可按 panelZone 过滤。 */
+/** 通用详情 KPI(L2/L3/L4),可按 panelZone 过滤。 */
 export async function fetchDetailKpis(
 	moduleCode: string,
 	factoryId = 1,

File diff ditekan karena terlalu besar
+ 940 - 8
Web/src/views/aidop/diagnosis/index.vue


+ 1061 - 0
Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue

@@ -0,0 +1,1061 @@
+<template>
+	<div class="dynamic-dashboard">
+		<header class="dynamic-dashboard__header">
+			<div>
+				<div class="dynamic-dashboard__eyebrow">{{ moduleCode }} · Dynamic Dashboard</div>
+				<h1>{{ page?.title || title }}</h1>
+				<p>{{ page?.subtitle || '由运营指标主数据和看板配置动态生成' }}</p>
+			</div>
+			<div class="dynamic-dashboard__actions">
+				<el-tag type="success" effect="dark">KPI Master</el-tag>
+				<el-button size="small" type="primary" plain @click="goModeling">配置指标</el-button>
+				<el-button size="small" type="warning" plain @click="goDashboardConfig">详情看板配置</el-button>
+				<el-button size="small" type="success" plain @click="goSmartDiagnosis">智慧诊断</el-button>
+				<el-button size="small" :loading="loading" @click="reload">刷新</el-button>
+			</div>
+		</header>
+
+		<DetailQueryBar dark band-title="基础查询:日期、产品、订单号、产线" @query="onQuery" @reset="onReset">
+			<SmartOpsBaseQueryFields v-model="baseQuery" compact />
+			<slot name="extra-fields" :query="extraQuery" />
+		</DetailQueryBar>
+
+		<el-alert
+			v-if="!loading && !page"
+			type="warning"
+			:closable="false"
+			show-icon
+			title="暂未读取到动态看板配置或指标数据,请先确认运营指标主数据和日值数据。"
+		/>
+
+		<div v-loading="loading" class="dynamic-dashboard__body">
+			<el-tabs v-if="useTabs" v-model="activeTab" class="dashboard-tabs" @tab-change="renderChartsSoon">
+				<el-tab-pane v-for="tab in pageTabs" :key="tab.metricCode" :label="tab.title" :name="tab.metricCode" />
+			</el-tabs>
+			<section v-if="useCardTabs && coreWidgets.length" class="dashboard-section dashboard-section--core-tabs">
+				<div class="dashboard-section__header">
+					<h2>核心指标</h2>
+					<span>{{ coreWidgets.length }} 个 L1 指标</span>
+				</div>
+				<div class="dashboard-grid dashboard-grid--core-tabs">
+					<article
+						v-for="widget in coreWidgets"
+						:key="widget.widgetCode"
+						class="dashboard-widget dashboard-widget--core-tab"
+						:class="[{ 'is-active': widget.tabMetricCode === activeTab }, { 'is-blink': hasBlinkMetric(widget) }]"
+						@click="selectCardTab(widget)"
+					>
+						<div class="dashboard-widget__header">
+							<div>
+								<h3>{{ widget.title || widget.metrics[0]?.metricName || '核心指标' }}</h3>
+								<p>{{ widget.metrics[0]?.department || 'L1 指标' }}</p>
+							</div>
+							<el-tag size="small" :type="tagType(widget.metrics[0]?.statusColor)" :class="{ 'blink-dot': shouldBlinkWidget(widget) }">
+								{{ statusText(widget.metrics[0]?.statusColor) }}
+							</el-tag>
+						</div>
+						<div class="metric-primary">
+							<div class="metric-primary__value" :class="valueClass(widget.metrics[0]?.statusColor)">
+								{{ formatValue(widget.metrics[0]?.currentValue) }}
+								<span>{{ widget.metrics[0]?.unit || '' }}</span>
+							</div>
+							<div class="metric-primary__meta">
+								<span v-if="widget.metrics[0]?.targetValue != null">目标 {{ formatValue(widget.metrics[0]?.targetValue) }}{{ widget.metrics[0]?.unit || '' }}</span>
+								<span v-if="widget.metrics[0]?.gapLabel">期量差 {{ widget.metrics[0]?.gapLabel }}</span>
+							</div>
+						</div>
+					</article>
+				</div>
+			</section>
+			<section v-if="useCardTabs && activeL2Metrics.length" class="dashboard-section dashboard-section--l2-cards">
+				<div class="dashboard-section__header">
+					<h2>L2 指标</h2>
+					<span>{{ activeL2Metrics.length }} 个下层指标</span>
+				</div>
+				<div class="dashboard-grid dashboard-grid--l2-cards">
+					<article
+						v-for="metric in activeL2Metrics"
+						:key="metric.metricCode"
+						class="dashboard-widget dashboard-widget--l2-card"
+						:class="{ 'is-blink': metric.isBlink, 'is-active': metric.metricCode === activeL2MetricCode }"
+						@click="selectL2Metric(metric)"
+					>
+						<div class="dashboard-widget__header">
+							<div>
+								<h3>{{ metric.metricName }}</h3>
+								<p>{{ metric.metricCode }}</p>
+							</div>
+							<el-tag size="small" :type="tagType(metric.statusColor)" :class="{ 'blink-dot': metric.isBlink }">
+								{{ statusText(metric.statusColor) }}
+							</el-tag>
+						</div>
+						<div class="metric-primary metric-primary--compact">
+							<div class="metric-primary__value" :class="valueClass(metric.statusColor)">
+								{{ formatValue(metric.currentValue) }}
+								<span>{{ metric.unit || '' }}</span>
+							</div>
+							<div class="metric-primary__meta">
+								<span v-if="metric.targetValue != null">目标 {{ formatValue(metric.targetValue) }}{{ metric.unit || '' }}</span>
+								<span v-if="metric.gapLabel">期量差 {{ metric.gapLabel }}</span>
+							</div>
+						</div>
+						<div v-if="formulaText(metric)" class="metric-formula">{{ formulaText(metric) }}</div>
+					</article>
+				</div>
+			</section>
+			<section v-if="useCardTabs && activeL3Metrics.length" class="dashboard-section dashboard-section--drill-cards">
+				<div class="dashboard-section__header">
+					<h2>L3 关键环节</h2>
+					<span>{{ activeL3Metrics.length }} 个下钻指标</span>
+				</div>
+				<div class="dashboard-grid dashboard-grid--l2-cards">
+					<article
+						v-for="metric in activeL3Metrics"
+						:key="metric.metricCode"
+						class="dashboard-widget dashboard-widget--l2-card dashboard-widget--drill-card"
+						:class="{ 'is-blink': metric.isBlink, 'is-active': metric.metricCode === activeL3MetricCode }"
+						@click="selectL3Metric(metric)"
+					>
+						<div class="dashboard-widget__header">
+							<div>
+								<h3>{{ metric.metricName }}</h3>
+								<p>{{ metric.department || metric.metricCode }}</p>
+							</div>
+							<el-tag size="small" :type="tagType(metric.statusColor)" :class="{ 'blink-dot': metric.isBlink }">
+								{{ statusText(metric.statusColor) }}
+							</el-tag>
+						</div>
+						<div class="metric-primary metric-primary--compact">
+							<div class="metric-primary__value" :class="valueClass(metric.statusColor)">
+								{{ formatValue(metric.currentValue) }}
+								<span>{{ metric.unit || '' }}</span>
+							</div>
+							<div class="metric-primary__meta">
+								<span v-if="metric.targetValue != null">目标 {{ formatValue(metric.targetValue) }}{{ metric.unit || '' }}</span>
+								<span v-if="metric.gapLabel">期量差 {{ metric.gapLabel }}</span>
+							</div>
+						</div>
+					</article>
+				</div>
+			</section>
+			<section v-if="useCardTabs && activeL4Metrics.length" class="dashboard-section dashboard-section--drill-cards">
+				<div class="dashboard-section__header">
+					<h2>L4 问题触点</h2>
+					<span>{{ activeL4Metrics.length }} 个责任/问题节点</span>
+				</div>
+				<div class="dashboard-grid dashboard-grid--l2-cards">
+					<article
+						v-for="metric in activeL4Metrics"
+						:key="metric.metricCode"
+						class="dashboard-widget dashboard-widget--l2-card dashboard-widget--drill-card"
+						:class="{ 'is-blink': metric.isBlink }"
+					>
+						<div class="dashboard-widget__header">
+							<div>
+								<h3>{{ metric.metricName }}</h3>
+								<p>{{ metric.department || metric.metricCode }}</p>
+							</div>
+							<el-tag size="small" :type="tagType(metric.statusColor)" :class="{ 'blink-dot': metric.isBlink }">
+								{{ statusText(metric.statusColor) }}
+							</el-tag>
+						</div>
+						<div class="metric-primary metric-primary--compact">
+							<div class="metric-primary__value" :class="valueClass(metric.statusColor)">
+								{{ formatValue(metric.currentValue) }}
+								<span>{{ metric.unit || '' }}</span>
+							</div>
+							<div class="metric-primary__meta">
+								<span v-if="metric.targetValue != null">目标 {{ formatValue(metric.targetValue) }}{{ metric.unit || '' }}</span>
+								<span v-if="metric.gapLabel">期量差 {{ metric.gapLabel }}</span>
+							</div>
+						</div>
+						<div v-if="formulaText(metric)" class="metric-formula">{{ formulaText(metric) }}</div>
+					</article>
+				</div>
+			</section>
+			<section v-for="section in visibleSections" :key="section.sectionCode" class="dashboard-section">
+				<div class="dashboard-section__header">
+					<h2>{{ section.title }}</h2>
+					<span>{{ section.widgets.length }} 个组件</span>
+				</div>
+				<div class="dashboard-grid">
+					<article
+						v-for="widget in section.widgets"
+						:key="widget.widgetCode"
+						class="dashboard-widget"
+						:class="[`dashboard-widget--${widget.widgetType}`, { 'is-blink': shouldBlinkWidget(widget) }]"
+						:style="{ gridColumn: `span ${spanToGrid(widget.span)}` }"
+					>
+						<div class="dashboard-widget__header">
+							<div>
+								<h3>{{ widget.title || widget.metrics[0]?.metricName || '指标组件' }}</h3>
+								<p>{{ widgetTypeLabel(widget.widgetType) }}</p>
+							</div>
+							<div class="dashboard-widget__tools">
+								<el-tag size="small" :type="tagType(widget.metrics[0]?.statusColor)" :class="{ 'blink-dot': hasBlinkMetric(widget) }">
+									{{ statusText(widget.metrics[0]?.statusColor) }}
+								</el-tag>
+								<el-button size="small" type="success" plain @click.stop="goMetricDiagnosis(widget)">诊断</el-button>
+							</div>
+						</div>
+
+						<template v-if="isChartWidget(widget.widgetType)">
+							<div v-if="!useCardTabs && shouldShowMetricSummary(widget)" class="metric-summary">
+								<div v-for="metric in widget.metrics" :key="metric.metricCode" class="metric-summary__item" :class="{ 'is-blink-row': metric.isBlink }">
+									<div class="metric-summary__main">
+										<span class="metric-summary__name">{{ metric.metricName }}</span>
+										<span v-if="formulaText(metric)" class="metric-summary__formula">{{ formulaText(metric) }}</span>
+									</div>
+									<div class="metric-summary__value">
+										<el-tag size="small" :type="tagType(metric.statusColor)" :class="{ 'blink-dot': metric.isBlink }">{{ statusText(metric.statusColor) }}</el-tag>
+										<strong>{{ formatValue(metric.currentValue) }}</strong>
+										<span>{{ metric.unit || '' }}</span>
+										<small v-if="metric.targetValue != null">目标 {{ formatValue(metric.targetValue) }}{{ metric.unit || '' }}</small>
+									</div>
+								</div>
+							</div>
+							<div :ref="(el) => setChartRef(el, widget.widgetCode)" class="dashboard-chart"></div>
+						</template>
+						<template v-else-if="widget.widgetType === 'metric_tree' || widget.widgetType === 'metric_table'">
+							<div class="metric-list">
+								<div v-for="metric in widget.metrics" :key="metric.metricCode" class="metric-list__row" :class="{ 'is-blink-row': metric.isBlink }">
+									<div class="metric-list__main">
+										<span class="metric-list__name">{{ metric.metricName }}</span>
+										<span v-if="formulaText(metric)" class="metric-list__formula">{{ formulaText(metric) }}</span>
+									</div>
+									<div class="metric-list__value">
+										<el-tag size="small" :type="tagType(metric.statusColor)" :class="{ 'blink-dot': metric.isBlink }">{{ statusText(metric.statusColor) }}</el-tag>
+										<strong>{{ formatValue(metric.currentValue) }}</strong>
+										<span>{{ metric.unit || '' }}</span>
+										<small v-if="metric.targetValue != null">目标 {{ formatValue(metric.targetValue) }}{{ metric.unit || '' }}</small>
+									</div>
+								</div>
+							</div>
+						</template>
+						<template v-else>
+							<div class="metric-primary">
+								<div class="metric-primary__value" :class="valueClass(widget.metrics[0]?.statusColor)">
+									{{ formatValue(widget.metrics[0]?.currentValue) }}
+									<span>{{ widget.metrics[0]?.unit || '' }}</span>
+								</div>
+								<div class="metric-primary__meta">
+									<span v-if="widget.metrics[0]?.targetValue != null">目标 {{ formatValue(widget.metrics[0]?.targetValue) }}{{ widget.metrics[0]?.unit || '' }}</span>
+									<span v-if="widget.metrics[0]?.gapLabel">期量差 {{ widget.metrics[0]?.gapLabel }}</span>
+								</div>
+							</div>
+							<div v-if="widget.widgetType === 'progress_card' || widget.widgetType === 'traffic_light_card'" class="metric-progress">
+								<div class="metric-progress__bar" :style="{ width: `${achievementPercent(widget.metrics[0])}%` }"></div>
+							</div>
+							<div v-if="formulaText(widget.metrics[0])" class="metric-formula">{{ formulaText(widget.metrics[0]) }}</div>
+						</template>
+					</article>
+				</div>
+			</section>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
+import { useRouter } from 'vue-router';
+import { ElMessage } from 'element-plus';
+import * as echarts from 'echarts';
+import DetailQueryBar from './DetailQueryBar.vue';
+import SmartOpsBaseQueryFields from './SmartOpsBaseQueryFields.vue';
+import {
+	fetchDynamicDashboardPage,
+	type DynamicDashboardMetric,
+	type DynamicDashboardPagePayload,
+	type DynamicDashboardWidget,
+} from '../../api/kanbanData';
+import {
+	baseQueryToApiParams,
+	emptySmartOpsBaseQuery,
+	summarizeSmartOpsBaseQuery,
+	type SmartOpsBaseQuery,
+} from '../utils/smartOpsBaseQuery';
+import { AIDOP_LAYOUT_SAVED } from '../utils/s4LayoutEvents';
+
+const props = defineProps<{
+	moduleCode: string;
+	title?: string;
+	extraQueryInit?: Record<string, string>;
+	extraQueryMapper?: (query: Record<string, string>) => Record<string, string>;
+}>();
+
+const router = useRouter();
+const loading = ref(false);
+const page = ref<DynamicDashboardPagePayload | null>(null);
+const baseQuery = ref<SmartOpsBaseQuery>(emptySmartOpsBaseQuery());
+const extraQuery = reactive<Record<string, string>>({ ...(props.extraQueryInit || {}) });
+const chartRefs = new Map<string, HTMLElement>();
+const chartInstances = new Map<string, echarts.ECharts>();
+
+const moduleCode = computed(() => props.moduleCode.toUpperCase());
+const title = computed(() => props.title || `${moduleCode.value} 运营指标看板`);
+const activeTab = ref('');
+const activeL2MetricCode = ref('');
+const activeL3MetricCode = ref('');
+const pageTabs = computed(() => page.value?.tabs || []);
+const useTabs = computed(() => page.value?.navigationMode === 'tabs_by_l1' && pageTabs.value.length > 0);
+const useCardTabs = computed(() => page.value?.navigationMode === 'l1_cards' && pageTabs.value.length > 0);
+const coreWidgets = computed(() =>
+	(page.value?.sections || [])
+		.find((section) => section.sectionCode === 'overview')
+		?.widgets?.filter((widget) => widget.metricLevel === 1) || []
+);
+const visibleSections = computed(() =>
+	(page.value?.sections || [])
+		.filter((section) => !(useCardTabs.value && section.sectionCode === 'overview'))
+		.map((section) => ({
+			...section,
+			widgets: (section.widgets || []).filter((widget) => {
+				if (!useTabs.value && !useCardTabs.value) return true;
+				return !widget.tabMetricCode || widget.tabMetricCode === activeTab.value;
+			}),
+		}))
+		.filter((x) => x.widgets?.length)
+);
+const activeL2Metrics = computed(() => {
+	if (!useCardTabs.value) return [];
+	const map = new Map<string, DynamicDashboardMetric>();
+	const parentId = metricIdByCode(activeTab.value);
+	for (const metric of page.value?.metrics || []) {
+		if (metric.metricLevel === 2 && sameId(metric.parentId, parentId)) map.set(metric.metricCode, metric);
+	}
+	for (const section of visibleSections.value) {
+		for (const widget of section.widgets || []) {
+			for (const metric of widget.metrics || []) {
+				if (metric.metricLevel === 2 && !map.has(metric.metricCode)) map.set(metric.metricCode, metric);
+			}
+		}
+	}
+	return Array.from(map.values());
+});
+const activeL3Metrics = computed(() => childMetrics(activeL2MetricCode.value, 3));
+const activeL4Metrics = computed(() => childMetrics(activeL3MetricCode.value, 4));
+
+function childMetrics(parentCode: string, level: number) {
+	if (!parentCode) return [];
+	const parentId = metricIdByCode(parentCode);
+	return (page.value?.metrics || [])
+		.filter((metric) => metric.metricLevel === level && sameId(metric.parentId, parentId))
+		.sort((a, b) => a.metricCode.localeCompare(b.metricCode));
+}
+
+function metricIdByCode(metricCode: string) {
+	const metric = (page.value?.metrics || []).find((x) => x.metricCode === metricCode);
+	return metric?.id ?? null;
+}
+
+function sameId(a: unknown, b: unknown) {
+	return a != null && b != null && String(a) === String(b);
+}
+
+function buildExtraParams() {
+	const mapper = props.extraQueryMapper || ((q: Record<string, string>) => q);
+	return { ...baseQueryToApiParams(baseQuery.value), ...mapper(extraQuery) };
+}
+
+async function reload() {
+	loading.value = true;
+	try {
+		disposeCharts();
+		page.value = await fetchDynamicDashboardPage(moduleCode.value, 1, buildExtraParams());
+		if (page.value?.defaultTabMetricCode && !activeTab.value) activeTab.value = page.value.defaultTabMetricCode;
+		if (!activeTab.value && page.value?.tabs?.length) activeTab.value = page.value.tabs[0].metricCode;
+		syncDrillSelection();
+		await nextTick();
+		renderCharts();
+	} finally {
+		loading.value = false;
+	}
+}
+
+function onQuery() {
+	reload();
+	ElMessage.success(`已应用筛选(${summarizeSmartOpsBaseQuery(baseQuery.value)})`);
+}
+
+function onReset() {
+	baseQuery.value = emptySmartOpsBaseQuery();
+	for (const key of Object.keys(extraQuery)) extraQuery[key] = props.extraQueryInit?.[key] ?? '';
+	reload();
+	ElMessage.info('已重置');
+}
+
+function goModeling() {
+	router.push({ path: '/aidop/smart-ops/modeling', query: { module: moduleCode.value } });
+}
+
+function goDashboardConfig() {
+	router.push({ path: '/aidop/smart-ops/dashboard-config', query: { module: moduleCode.value } });
+}
+
+function goSmartDiagnosis() {
+	router.push({
+		path: '/aidop/smart-diagnosis',
+		query: {
+			module: moduleCode.value,
+			...buildExtraParams(),
+		},
+	});
+}
+
+function goMetricDiagnosis(widget: DynamicDashboardWidget) {
+	const metricCode = widget.tabMetricCode || widget.metrics[0]?.metricCode;
+	router.push({
+		path: '/aidop/smart-diagnosis',
+		query: {
+			module: moduleCode.value,
+			metricCode: metricCode || '',
+			...buildExtraParams(),
+		},
+	});
+}
+
+function selectCardTab(widget: DynamicDashboardWidget) {
+	if (!widget.tabMetricCode) return;
+	disposeCharts();
+	activeTab.value = widget.tabMetricCode;
+	activeL2MetricCode.value = '';
+	activeL3MetricCode.value = '';
+	syncDrillSelection();
+	renderChartsSoon();
+}
+
+function selectL2Metric(metric: DynamicDashboardMetric) {
+	activeL2MetricCode.value = metric.metricCode;
+	activeL3MetricCode.value = '';
+	syncDrillSelection();
+}
+
+function selectL3Metric(metric: DynamicDashboardMetric) {
+	activeL3MetricCode.value = metric.metricCode;
+}
+
+function syncDrillSelection() {
+	if (!activeL2Metrics.value.some((metric) => metric.metricCode === activeL2MetricCode.value)) {
+		activeL2MetricCode.value = mostSevereMetric(activeL2Metrics.value)?.metricCode || '';
+	}
+	if (!activeL3Metrics.value.some((metric) => metric.metricCode === activeL3MetricCode.value)) {
+		activeL3MetricCode.value = mostSevereMetric(activeL3Metrics.value)?.metricCode || '';
+	}
+}
+
+function mostSevereMetric(metrics: DynamicDashboardMetric[]) {
+	return [...metrics].sort((a, b) => statusRank(b.statusColor) - statusRank(a.statusColor) || a.metricCode.localeCompare(b.metricCode))[0];
+}
+
+function statusRank(status?: string | null) {
+	const key = String(status || '').toLowerCase();
+	if (key === 'red') return 3;
+	if (key === 'yellow') return 2;
+	if (key === 'green') return 1;
+	return 0;
+}
+
+function spanToGrid(span?: number) {
+	const s = Number(span || 6);
+	if (s >= 24) return 12;
+	if (s >= 12) return 6;
+	return 3;
+}
+
+function formatValue(value?: number | null) {
+	if (value == null || Number.isNaN(Number(value))) return '—';
+	const n = Number(value);
+	return Math.abs(n - Math.round(n)) < 1e-9 ? String(Math.round(n)) : String(Math.round(n * 100) / 100);
+}
+
+function tagType(status?: string | null) {
+	const s = String(status || '').toLowerCase();
+	if (s === 'green') return 'success';
+	if (s === 'yellow') return 'warning';
+	if (s === 'red') return 'danger';
+	return 'info';
+}
+
+function statusText(status?: string | null) {
+	const s = String(status || '').toLowerCase();
+	if (s === 'green') return '达成';
+	if (s === 'yellow') return '风险';
+	if (s === 'red') return '未达';
+	return '—';
+}
+
+function valueClass(status?: string | null) {
+	return `is-${String(status || 'none').toLowerCase()}`;
+}
+
+function widgetTypeLabel(type?: string) {
+	const map: Record<string, string> = {
+		metric_card: '指标卡',
+		traffic_light_card: '红绿灯卡',
+		progress_card: '进度指标',
+		bar_compare: '目标/实际条形图',
+		funnel_chart: '流程漏斗',
+		gauge: '仪表盘指标',
+		trend_line: '趋势图',
+		line_trend: '趋势折线',
+		heatmap: '负荷热力图',
+		threshold_line: '阈值折线',
+		area_chart: '面积趋势',
+		metric_tree: '分组指标',
+		metric_table: '指标表格',
+	};
+	return map[String(type || '')] || '动态组件';
+}
+
+function isChartWidget(type?: string) {
+	return ['trend_line', 'line_trend', 'bar_compare', 'funnel_chart', 'gauge', 'heatmap', 'threshold_line', 'area_chart'].includes(String(type || ''));
+}
+
+function formulaText(metric?: DynamicDashboardMetric) {
+	return metric?.formulaPreview || metric?.formulaExpr || metric?.formula || '';
+}
+
+function achievementPercent(metric?: DynamicDashboardMetric) {
+	const current = Number(metric?.currentValue);
+	const target = Number(metric?.targetValue);
+	if (!Number.isFinite(current) || !Number.isFinite(target) || target === 0) return 0;
+	return Math.max(0, Math.min(100, Math.round((current / target) * 100)));
+}
+
+function hasBlinkMetric(widget: DynamicDashboardWidget) {
+	return (widget.metrics || []).some((metric) => metric.isBlink);
+}
+
+function shouldBlinkWidget(widget: DynamicDashboardWidget) {
+	return widget.widgetType !== 'bar_compare' && hasBlinkMetric(widget);
+}
+
+function shouldShowMetricSummary(widget: DynamicDashboardWidget) {
+	return widget.metricLevel > 1 || (widget.metrics || []).length > 1;
+}
+
+function isAlertMetric(metric?: DynamicDashboardMetric) {
+	return Boolean(metric?.isBlink) || String(metric?.statusColor || '').toLowerCase() === 'red';
+}
+
+function setChartRef(el: Element | null, code: string) {
+	if (el instanceof HTMLElement) chartRefs.set(code, el);
+	else {
+		chartRefs.delete(code);
+		disposeChart(code);
+	}
+}
+
+function renderCharts() {
+	const chartWidgets = visibleSections.value.flatMap((section) => section.widgets).filter((w) => isChartWidget(w.widgetType));
+	for (const widget of chartWidgets) renderChartWidget(widget);
+}
+
+function renderChartsSoon() {
+	nextTick(() => window.requestAnimationFrame(renderCharts));
+}
+
+function renderChartWidget(widget: DynamicDashboardWidget) {
+	const el = chartRefs.get(widget.widgetCode);
+	if (!el) return;
+	if (!el.isConnected) {
+		chartRefs.delete(widget.widgetCode);
+		return;
+	}
+	let inst = chartInstances.get(widget.widgetCode);
+	if (inst && inst.getDom() !== el) {
+		inst.dispose();
+		chartInstances.delete(widget.widgetCode);
+		inst = undefined;
+	}
+	if (!inst) inst = echarts.init(el);
+	chartInstances.set(widget.widgetCode, inst);
+	const type = String(widget.widgetType || '');
+	if (type === 'gauge') {
+		const metric = widget.metrics[0];
+		inst.setOption(buildGaugeOption(metric), true);
+		window.requestAnimationFrame(() => inst.resize());
+		return;
+	}
+	if (type === 'bar_compare' || type === 'heatmap' || type === 'funnel_chart') {
+		inst.setOption(buildCompareOption(widget), true);
+		window.requestAnimationFrame(() => inst.resize());
+		return;
+	}
+	inst.setOption(buildLineOption(widget), true);
+	window.requestAnimationFrame(() => inst.resize());
+}
+
+function disposeChart(code: string) {
+	const inst = chartInstances.get(code);
+	if (!inst) return;
+	inst.dispose();
+	chartInstances.delete(code);
+}
+
+function disposeCharts() {
+	chartInstances.forEach((chart) => chart.dispose());
+	chartInstances.clear();
+	chartRefs.clear();
+}
+
+function buildLineOption(widget: DynamicDashboardWidget) {
+	const labels = Array.from(new Set(widget.metrics.flatMap((metric) => metric.trend.map((point) => point.date)))).sort();
+	const series = widget.metrics.map((metric) => {
+		const byDate = new Map(metric.trend.map((point) => [point.date, point.value]));
+		return {
+			name: metric.metricName,
+			type: widget.widgetType === 'area_chart' ? 'line' : 'line',
+			smooth: true,
+			areaStyle: widget.widgetType === 'area_chart' ? {} : undefined,
+			markLine: widget.widgetType === 'threshold_line' && metric.targetValue != null ? { data: [{ yAxis: metric.targetValue, name: '目标' }] } : undefined,
+			label: { show: labels.length <= 14, color: '#cbd5e1', formatter: (p: any) => formatValue(p.value) },
+			data: labels.map((date) => byDate.get(date) ?? null),
+		};
+	});
+	return {
+		tooltip: { trigger: 'axis' },
+		legend: { top: 0, textStyle: { color: '#94a3b8' } },
+		grid: { left: 35, right: 20, bottom: 28, top: 42 },
+		xAxis: { type: 'category', data: labels, axisLabel: { color: '#94a3b8' } },
+		yAxis: { type: 'value', axisLabel: { color: '#94a3b8' }, splitLine: { lineStyle: { color: '#263244' } } },
+		series,
+	};
+}
+
+function buildGaugeOption(metric?: DynamicDashboardMetric) {
+	const percent = achievementPercent(metric);
+	return {
+		series: [{
+			type: 'gauge',
+			min: 0,
+			max: 120,
+			progress: { show: true, width: 12 },
+			axisLine: { lineStyle: { width: 12 } },
+			detail: { valueAnimation: true, formatter: `${percent}%`, color: '#e2e8f0' },
+			data: [{ value: percent, name: metric?.metricName || '达成率' }],
+		}],
+	};
+}
+
+function buildCompareOption(widget: DynamicDashboardWidget) {
+	const metrics = widget.metrics || [];
+	if (widget.widgetType === 'funnel_chart') {
+		return {
+			tooltip: { trigger: 'item' },
+			series: [{
+				type: 'funnel',
+				left: 20,
+				right: 20,
+				top: 20,
+				bottom: 10,
+				label: { show: true, formatter: '{b}: {c}' },
+				data: metrics.map((metric) => ({ name: metric.metricName, value: Number(metric.currentValue ?? 0) })),
+			}],
+		};
+	}
+	const actualData = metrics.map((metric) => {
+		const alert = isAlertMetric(metric);
+		return {
+			value: metric.currentValue ?? 0,
+			itemStyle: {
+				color: alert ? '#fb7185' : '#60a5fa',
+				shadowBlur: alert ? 16 : 0,
+				shadowColor: alert ? 'rgba(251, 113, 133, 0.75)' : 'transparent',
+			},
+			label: { color: alert ? '#fecdd3' : '#dbeafe' },
+		};
+	});
+	return {
+		tooltip: { trigger: 'axis' },
+		legend: { top: 0, textStyle: { color: '#94a3b8' } },
+		grid: { left: 55, right: 20, bottom: 42, top: 50 },
+		xAxis: { type: 'category', data: metrics.map((metric) => metric.metricName), axisLabel: { color: '#94a3b8', interval: 0, rotate: 20 } },
+		yAxis: { type: 'value', axisLabel: { color: '#94a3b8' }, splitLine: { lineStyle: { color: '#263244' } } },
+		series: [
+			{
+				name: '目标值',
+				type: 'bar',
+				data: metrics.map((metric) => metric.targetValue ?? 0),
+				itemStyle: { color: '#64748b' },
+				label: { show: true, position: 'top', color: '#cbd5e1', formatter: (p: any) => formatValue(p.value) },
+			},
+			{
+				name: '实际达成',
+				type: 'bar',
+				data: actualData,
+				label: { show: true, position: 'top', color: '#dbeafe', formatter: (p: any) => formatValue(p.value) },
+			},
+		],
+	};
+}
+
+function onResize() {
+	chartInstances.forEach((chart) => chart.resize());
+}
+
+function onLayoutSaved(event: Event) {
+	const detail = (event as CustomEvent<{ moduleCode?: string }>).detail;
+	if (!detail?.moduleCode || detail.moduleCode.toUpperCase() === moduleCode.value) reload();
+}
+
+onMounted(() => {
+	reload();
+	window.addEventListener('resize', onResize);
+	window.addEventListener(AIDOP_LAYOUT_SAVED, onLayoutSaved as EventListener);
+});
+
+onUnmounted(() => {
+	window.removeEventListener('resize', onResize);
+	window.removeEventListener(AIDOP_LAYOUT_SAVED, onLayoutSaved as EventListener);
+	disposeCharts();
+});
+
+watch(page, () => renderChartsSoon());
+</script>
+
+<style scoped>
+.dynamic-dashboard {
+	min-height: 100%;
+	background: #0b0f19;
+	color: #e2e8f0;
+	padding: 16px;
+	box-sizing: border-box;
+	overflow: auto;
+}
+
+.dynamic-dashboard__header {
+	display: flex;
+	justify-content: space-between;
+	gap: 16px;
+	align-items: center;
+	padding: 18px 20px;
+	margin-bottom: 14px;
+	border: 1px solid #1e293b;
+	border-radius: 14px;
+	background: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.7));
+}
+
+.dynamic-dashboard__eyebrow {
+	font-size: 12px;
+	color: #60a5fa;
+	font-weight: 700;
+	margin-bottom: 4px;
+}
+
+.dynamic-dashboard__header h1 {
+	margin: 0;
+	font-size: 22px;
+}
+
+.dynamic-dashboard__header p {
+	margin: 6px 0 0;
+	color: #94a3b8;
+	font-size: 13px;
+}
+
+.dynamic-dashboard__actions {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	flex-wrap: wrap;
+	justify-content: flex-end;
+}
+
+.dynamic-dashboard__body {
+	min-height: 360px;
+}
+
+.dashboard-tabs {
+	margin-top: 12px;
+	--el-text-color-primary: #e2e8f0;
+	--el-color-primary: #60a5fa;
+}
+
+.dashboard-section {
+	margin-top: 16px;
+}
+
+.dashboard-section__header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	margin-bottom: 10px;
+	color: #94a3b8;
+}
+
+.dashboard-section__header h2 {
+	margin: 0;
+	font-size: 16px;
+	color: #f8fafc;
+}
+
+.dashboard-grid {
+	display: grid;
+	grid-template-columns: repeat(12, minmax(0, 1fr));
+	gap: 14px;
+}
+
+.dashboard-grid--core-tabs {
+	grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
+}
+
+.dashboard-grid--l2-cards {
+	grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+}
+
+.dashboard-widget {
+	min-height: 160px;
+	border: 1px solid #1e293b;
+	border-radius: 14px;
+	padding: 16px;
+	background: rgba(15, 23, 42, 0.78);
+	box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
+}
+
+.dashboard-widget--core-tab {
+	min-height: 150px;
+	cursor: pointer;
+	transition: border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
+}
+
+.dashboard-widget--core-tab:hover {
+	transform: translateY(-2px);
+	border-color: rgba(96, 165, 250, 0.65);
+}
+
+.dashboard-widget--core-tab.is-active {
+	border-color: #60a5fa;
+	box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.55), 0 18px 40px rgba(37, 99, 235, 0.22);
+}
+
+.dashboard-widget--l2-card {
+	min-height: 132px;
+	background: rgba(15, 23, 42, 0.68);
+	cursor: pointer;
+	transition: border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
+}
+
+.dashboard-widget--l2-card:hover,
+.dashboard-widget--l2-card.is-active {
+	transform: translateY(-2px);
+	border-color: rgba(45, 212, 191, 0.68);
+	box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.35), 0 16px 36px rgba(13, 148, 136, 0.16);
+}
+
+.dashboard-widget--drill-card {
+	min-height: 124px;
+	background: rgba(15, 23, 42, 0.56);
+}
+
+.dashboard-widget.is-blink,
+.is-blink-row {
+	animation: dashboard-red-pulse 1.2s ease-in-out infinite;
+}
+
+.blink-dot {
+	animation: dashboard-red-pulse 1.2s ease-in-out infinite;
+}
+
+@keyframes dashboard-red-pulse {
+	0%, 100% {
+		box-shadow: 0 0 0 rgba(248, 113, 113, 0);
+	}
+	50% {
+		box-shadow: 0 0 18px rgba(248, 113, 113, 0.85);
+	}
+}
+
+.dashboard-widget--trend_line {
+	min-height: 330px;
+}
+
+.dashboard-widget__header {
+	display: flex;
+	justify-content: space-between;
+	gap: 12px;
+	margin-bottom: 12px;
+}
+
+.dashboard-widget__tools {
+	display: inline-flex;
+	align-items: center;
+	gap: 6px;
+	flex-shrink: 0;
+}
+
+.dashboard-widget__header h3 {
+	margin: 0;
+	font-size: 15px;
+	color: #f8fafc;
+}
+
+.dashboard-widget__header p {
+	margin: 4px 0 0;
+	color: #64748b;
+	font-size: 12px;
+}
+
+.metric-primary__value {
+	font-size: 38px;
+	font-weight: 800;
+	color: #e2e8f0;
+	letter-spacing: -1px;
+}
+
+.metric-primary__value span {
+	font-size: 14px;
+	color: #94a3b8;
+	margin-left: 4px;
+}
+
+.metric-primary--compact .metric-primary__value {
+	font-size: 28px;
+}
+
+.metric-primary__value.is-green {
+	color: #34d399;
+}
+
+.metric-primary__value.is-yellow {
+	color: #fbbf24;
+}
+
+.metric-primary__value.is-red {
+	color: #fb7185;
+}
+
+.metric-primary__meta {
+	display: flex;
+	gap: 12px;
+	color: #94a3b8;
+	font-size: 12px;
+	margin-top: 8px;
+	flex-wrap: wrap;
+}
+
+.metric-progress {
+	height: 8px;
+	border-radius: 999px;
+	background: #1e293b;
+	overflow: hidden;
+	margin-top: 16px;
+}
+
+.metric-progress__bar {
+	height: 100%;
+	background: linear-gradient(90deg, #60a5fa, #34d399);
+	border-radius: inherit;
+}
+
+.metric-formula,
+.metric-list__formula,
+.metric-summary__formula {
+	color: #64748b;
+	font-size: 12px;
+	line-height: 1.5;
+	margin-top: 10px;
+}
+
+.metric-list {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+}
+
+.metric-list__row {
+	display: flex;
+	justify-content: space-between;
+	gap: 14px;
+	padding: 12px;
+	border-radius: 10px;
+	background: rgba(30, 41, 59, 0.62);
+	border: 1px solid rgba(51, 65, 85, 0.9);
+}
+
+.metric-list__main {
+	min-width: 0;
+	flex: 1;
+}
+
+.metric-list__name {
+	display: block;
+	color: #e2e8f0;
+	font-weight: 600;
+}
+
+.metric-list__value {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	color: #94a3b8;
+	white-space: nowrap;
+}
+
+.metric-list__value strong {
+	color: #f8fafc;
+	font-size: 18px;
+}
+
+.metric-list__value small {
+	color: #64748b;
+}
+
+.metric-summary {
+	display: grid;
+	grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+	gap: 10px;
+	margin-bottom: 14px;
+}
+
+.metric-summary__item {
+	display: flex;
+	justify-content: space-between;
+	gap: 12px;
+	padding: 10px 12px;
+	border-radius: 10px;
+	background: rgba(30, 41, 59, 0.58);
+	border: 1px solid rgba(51, 65, 85, 0.86);
+}
+
+.metric-summary__main {
+	min-width: 0;
+}
+
+.metric-summary__name {
+	display: block;
+	color: #e2e8f0;
+	font-weight: 600;
+	white-space: nowrap;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	max-width: 220px;
+}
+
+.metric-summary__value {
+	display: flex;
+	align-items: center;
+	gap: 6px;
+	color: #94a3b8;
+	white-space: nowrap;
+}
+
+.metric-summary__value strong {
+	color: #f8fafc;
+	font-size: 16px;
+}
+
+.metric-summary__value small {
+	color: #64748b;
+}
+
+.dashboard-chart {
+	height: 260px;
+}
+
+@media (max-width: 1200px) {
+	.dashboard-widget {
+		grid-column: span 12 !important;
+	}
+}
+</style>

+ 584 - 0
Web/src/views/aidop/kanban/dashboardConfig.vue

@@ -0,0 +1,584 @@
+<template>
+	<div class="dashboard-config">
+		<header class="dashboard-config__header">
+			<div>
+				<h1>详情看板配置</h1>
+				<p>配置 S1-S9 详情看板的页面模式、组件类型、绑定指标和图表样式。</p>
+			</div>
+			<div class="dashboard-config__actions">
+				<el-button :loading="loading" @click="loadConfig">重新加载</el-button>
+				<el-button type="primary" :loading="saving" @click="saveConfig">保存配置</el-button>
+				<el-button type="success" plain @click="goPreview">预览看板</el-button>
+			</div>
+		</header>
+
+		<el-card class="dashboard-config__toolbar" shadow="never">
+			<el-form inline>
+				<el-form-item label="模块">
+					<el-select v-model="moduleCode" style="width: 180px" @change="loadConfig">
+						<el-option v-for="m in modules" :key="m.value" :label="m.label" :value="m.value" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="页面模式">
+					<el-radio-group v-model="navigationMode">
+						<el-radio-button label="tabs_by_l1">按 L1 分 Tab</el-radio-button>
+						<el-radio-button label="l1_cards">L1 指标卡片</el-radio-button>
+						<el-radio-button label="single_page">平铺</el-radio-button>
+						<el-radio-button label="auto">自动</el-radio-button>
+					</el-radio-group>
+				</el-form-item>
+				<el-form-item label="默认 Tab">
+					<el-select v-model="defaultTabMetricCode" clearable filterable style="width: 220px">
+						<el-option v-for="tab in tabs" :key="tab.metricCode" :label="tab.title" :value="tab.metricCode" />
+					</el-select>
+				</el-form-item>
+			</el-form>
+		</el-card>
+
+		<el-card class="dashboard-config__body" shadow="never">
+			<template #header>
+				<div class="dashboard-config__card-head">
+					<span>组件配置</span>
+					<span class="dashboard-config__hint">保存后对应详情看板自动使用当前配置</span>
+				</div>
+			</template>
+
+			<div class="widget-preview-panel">
+				<div class="widget-preview-panel__title">
+					<span>组件类型示意图</span>
+					<small>选择组件类型前,可先参考不同组件的视觉形态和适用场景。</small>
+				</div>
+				<div class="widget-preview-list">
+					<div v-for="item in widgetTypePreviews" :key="item.value" class="widget-preview-card">
+						<div class="widget-preview-card__mock" :class="`is-${item.mock}`">
+							<template v-if="item.mock === 'metric'">
+								<div class="mock-line mock-line--short"></div>
+								<div class="mock-value"></div>
+								<div class="mock-bar"></div>
+							</template>
+							<template v-else-if="item.mock === 'bars'">
+								<div class="mock-bars">
+									<span style="height: 38%"></span>
+									<span style="height: 72%"></span>
+									<span style="height: 54%"></span>
+									<span style="height: 86%"></span>
+								</div>
+							</template>
+							<template v-else-if="item.mock === 'line'">
+								<div class="mock-line-chart">
+									<span></span>
+									<span></span>
+									<span></span>
+									<span></span>
+								</div>
+							</template>
+							<template v-else-if="item.mock === 'gauge'">
+								<div class="mock-gauge"></div>
+								<div class="mock-gauge-value"></div>
+							</template>
+							<template v-else-if="item.mock === 'funnel'">
+								<div class="mock-funnel">
+									<span></span>
+									<span></span>
+									<span></span>
+								</div>
+							</template>
+							<template v-else-if="item.mock === 'table'">
+								<div class="mock-table">
+									<span v-for="n in 6" :key="n"></span>
+								</div>
+							</template>
+						</div>
+						<div class="widget-preview-card__name">{{ item.label }}</div>
+						<div class="widget-preview-card__desc">{{ item.desc }}</div>
+					</div>
+				</div>
+			</div>
+
+			<el-table v-loading="loading" :data="widgets" border stripe row-key="WidgetCode">
+				<el-table-column label="显示" width="72" align="center">
+					<template #default="{ row }">
+						<el-switch v-model="row.Enabled" />
+					</template>
+				</el-table-column>
+				<el-table-column label="Tab" min-width="170">
+					<template #default="{ row }">
+						<el-select v-model="row.TabMetricCode" clearable placeholder="全局" style="width: 100%">
+							<el-option v-for="tab in tabs" :key="tab.metricCode" :label="tab.title" :value="tab.metricCode" />
+						</el-select>
+					</template>
+				</el-table-column>
+				<el-table-column label="区块" min-width="150">
+					<template #default="{ row }">
+						<el-select v-model="row.SectionCode" style="width: 100%">
+							<el-option v-for="s in sections" :key="s.value" :label="s.label" :value="s.value" />
+						</el-select>
+					</template>
+				</el-table-column>
+				<el-table-column label="组件类型" min-width="170">
+					<template #default="{ row }">
+						<el-select v-model="row.WidgetType" style="width: 100%">
+							<el-option v-for="w in widgetTypes" :key="w.value" :label="w.label" :value="w.value" />
+						</el-select>
+					</template>
+				</el-table-column>
+				<el-table-column label="标题" min-width="180">
+					<template #default="{ row }">
+						<el-input v-model="row.Title" />
+					</template>
+				</el-table-column>
+				<el-table-column label="绑定指标" min-width="260">
+					<template #default="{ row }">
+						<el-select v-model="row.MetricCodes" multiple filterable collapse-tags collapse-tags-tooltip style="width: 100%">
+							<el-option v-for="m in metrics" :key="m.metricCode" :label="`${m.metricCode} · ${m.metricName}`" :value="m.metricCode" />
+						</el-select>
+					</template>
+				</el-table-column>
+				<el-table-column label="宽度" width="96">
+					<template #default="{ row }">
+						<el-input-number v-model="row.Span" :min="6" :max="24" :step="6" controls-position="right" style="width: 76px" />
+					</template>
+				</el-table-column>
+				<el-table-column label="排序" width="96">
+					<template #default="{ row }">
+						<el-input-number v-model="row.SortNo" :min="0" :max="9999" controls-position="right" style="width: 76px" />
+					</template>
+				</el-table-column>
+				<el-table-column label="样式 JSON" min-width="220">
+					<template #default="{ row }">
+						<el-input v-model="row.StyleJson" placeholder='如 {"showLegend":true}' />
+					</template>
+				</el-table-column>
+			</el-table>
+		</el-card>
+	</div>
+</template>
+
+<script setup lang="ts" name="aidopDashboardConfig">
+import { computed, onMounted, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { ElMessage } from 'element-plus';
+import {
+	fetchDynamicDashboardPage,
+	saveDynamicDashboardPage,
+	type DynamicDashboardMetric,
+	type DynamicDashboardWidgetSave,
+} from '../api/kanbanData';
+
+type EditableWidget = DynamicDashboardWidgetSave & { Enabled: boolean };
+
+const route = useRoute();
+const router = useRouter();
+
+const modules = [
+	{ value: 'S1', label: 'S1 产销协同' },
+	{ value: 'S2', label: 'S2 订单排程' },
+	{ value: 'S3', label: 'S3 供应协同' },
+	{ value: 'S4', label: 'S4 采购执行' },
+	{ value: 'S5', label: 'S5 物料仓储' },
+	{ value: 'S6', label: 'S6 生产执行' },
+	{ value: 'S7', label: 'S7 成品仓储' },
+	{ value: 'S9', label: 'S9 运营指标' },
+];
+
+const sections = [
+	{ value: 'overview', label: '指标概览' },
+	{ value: 'process_efficiency', label: '流程效率分析' },
+	{ value: 'quality_trend', label: '质量与满意度趋势' },
+	{ value: 'resource_load', label: '人效与资源负荷' },
+	{ value: 'inventory_warning', label: '库存预警' },
+	{ value: 'breakdown', label: '指标分解' },
+	{ value: 'trend', label: '趋势分析' },
+];
+
+const widgetTypes = [
+	{ value: 'metric_card', label: '指标卡' },
+	{ value: 'traffic_light_card', label: '红绿灯卡' },
+	{ value: 'progress_card', label: '进度卡' },
+	{ value: 'bar_compare', label: '目标/实际条形图' },
+	{ value: 'funnel_chart', label: '流程漏斗' },
+	{ value: 'line_trend', label: '趋势折线' },
+	{ value: 'gauge', label: '仪表盘' },
+	{ value: 'heatmap', label: '热力图' },
+	{ value: 'threshold_line', label: '阈值折线' },
+	{ value: 'area_chart', label: '面积图' },
+	{ value: 'metric_tree', label: '分组指标' },
+	{ value: 'metric_table', label: '指标表格' },
+];
+
+const widgetTypePreviews = [
+	{ value: 'metric_card', label: '指标卡', desc: '突出单个 KPI 当前值', mock: 'metric' },
+	{ value: 'progress_card', label: '进度卡', desc: '展示目标达成进度', mock: 'metric' },
+	{ value: 'traffic_light_card', label: '红绿灯卡', desc: '突出达标/风险/未达状态', mock: 'metric' },
+	{ value: 'bar_compare', label: '目标/实际条形图', desc: '对比多指标目标与实际', mock: 'bars' },
+	{ value: 'line_trend', label: '趋势折线', desc: '观察指标时间趋势', mock: 'line' },
+	{ value: 'threshold_line', label: '阈值折线', desc: '趋势叠加预警阈值', mock: 'line' },
+	{ value: 'gauge', label: '仪表盘', desc: '展示达成率或比例类指标', mock: 'gauge' },
+	{ value: 'funnel_chart', label: '流程漏斗', desc: '展示流程转化和损耗', mock: 'funnel' },
+	{ value: 'metric_table', label: '指标表格', desc: '展示多指标明细列表', mock: 'table' },
+];
+
+const moduleCode = ref(String(route.query.module || 'S1').toUpperCase());
+const navigationMode = ref('l1_cards');
+const defaultTabMetricCode = ref<string | null>(null);
+const loading = ref(false);
+const saving = ref(false);
+const widgets = ref<EditableWidget[]>([]);
+const allMetrics = ref<DynamicDashboardMetric[]>([]);
+const tabs = ref<Array<{ metricCode: string; title: string; sortNo: number }>>([]);
+
+const metrics = computed(() => {
+	const map = new Map<string, DynamicDashboardMetric>();
+	for (const metric of allMetrics.value) map.set(metric.metricCode, metric);
+	return Array.from(map.values()).sort((a, b) => a.metricLevel - b.metricLevel || a.metricCode.localeCompare(b.metricCode));
+});
+
+async function loadConfig() {
+	loading.value = true;
+	try {
+		const page = await fetchDynamicDashboardPage(moduleCode.value);
+		navigationMode.value = page?.navigationMode || 'l1_cards';
+		defaultTabMetricCode.value = page?.defaultTabMetricCode || null;
+		tabs.value = page?.tabs || [];
+		const rows: EditableWidget[] = [];
+		const metricRows: DynamicDashboardMetric[] = [];
+		for (const section of page?.sections || []) {
+			for (const widget of section.widgets || []) {
+				rows.push({
+					Enabled: true,
+					SectionCode: widget.sectionCode,
+					TabMetricCode: widget.tabMetricCode || null,
+					WidgetCode: widget.widgetCode,
+					WidgetType: widget.widgetType,
+					Title: widget.title || '',
+					MetricLevel: widget.metricLevel,
+					MetricCodes: widget.metrics.map((m) => m.metricCode),
+					Span: widget.span,
+					SortNo: widget.sortNo,
+					StyleJson: widget.style && Object.keys(widget.style).length ? JSON.stringify(widget.style) : '',
+				});
+				metricRows.push(...widget.metrics);
+			}
+		}
+		widgets.value = rows;
+		allMetrics.value = page?.metrics?.length ? page.metrics : metricRows;
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function saveConfig() {
+	saving.value = true;
+	try {
+		const body = {
+			NavigationMode: navigationMode.value,
+			TabSource: 'l1_metrics',
+			DefaultTabMetricCode: defaultTabMetricCode.value,
+			Widgets: widgets.value
+				.filter((w) => w.Enabled)
+				.map((w) => ({
+					SectionCode: w.SectionCode,
+					TabMetricCode: w.TabMetricCode || null,
+					WidgetCode: w.WidgetCode,
+					WidgetType: w.WidgetType,
+					Title: w.Title,
+					MetricLevel: w.MetricLevel,
+					MetricCodes: w.MetricCodes,
+					Span: w.Span,
+					SortNo: w.SortNo,
+					StyleJson: w.StyleJson,
+				})),
+		};
+		const res = await saveDynamicDashboardPage(moduleCode.value, body);
+		if (!res.ok) {
+			ElMessage.error(res.message || '保存失败');
+			return;
+		}
+		ElMessage.success(`已保存 ${moduleCode.value} 详情看板配置`);
+		await loadConfig();
+	} finally {
+		saving.value = false;
+	}
+}
+
+function goPreview() {
+	router.push(`/aidop/smart-ops/${moduleCode.value.toLowerCase()}`);
+}
+
+onMounted(loadConfig);
+</script>
+
+<style scoped>
+.dashboard-config {
+	box-sizing: border-box;
+	min-height: 100%;
+	padding: 16px;
+	background: #f5f7fb;
+}
+
+.dashboard-config__header {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 16px;
+	margin-bottom: 14px;
+	padding: 18px 20px;
+	background: #fff;
+	border-radius: 12px;
+}
+
+.dashboard-config__header h1 {
+	margin: 0;
+	font-size: 22px;
+}
+
+.dashboard-config__header p {
+	margin: 6px 0 0;
+	color: #6b7280;
+}
+
+.dashboard-config__actions {
+	display: flex;
+	gap: 8px;
+	flex-wrap: wrap;
+	justify-content: flex-end;
+}
+
+.dashboard-config__toolbar {
+	margin-bottom: 14px;
+}
+
+.dashboard-config__card-head {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+}
+
+.dashboard-config__hint {
+	font-size: 12px;
+	color: #909399;
+}
+
+.widget-preview-panel {
+	padding: 12px 14px;
+	margin-bottom: 14px;
+	border: 1px solid #e5e7eb;
+	border-radius: 10px;
+	background: #f8fafc;
+}
+
+.widget-preview-panel__title {
+	display: flex;
+	align-items: baseline;
+	gap: 10px;
+	margin-bottom: 10px;
+}
+
+.widget-preview-panel__title span {
+	font-weight: 700;
+	color: #1f2937;
+}
+
+.widget-preview-panel__title small {
+	color: #6b7280;
+}
+
+.widget-preview-list {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
+	gap: 10px;
+}
+
+.widget-preview-card {
+	padding: 10px;
+	border: 1px solid #dbe3ef;
+	border-radius: 8px;
+	background: #fff;
+	text-align: center;
+	transition: border-color 0.18s ease, box-shadow 0.18s ease;
+}
+
+.widget-preview-card:hover {
+	border-color: #409eff;
+	box-shadow: 0 6px 16px rgba(64, 158, 255, 0.16);
+}
+
+.widget-preview-card__mock {
+	position: relative;
+	height: 54px;
+	margin-bottom: 8px;
+	border-radius: 6px;
+	background: #132033;
+	border: 1px solid #315070;
+	overflow: hidden;
+}
+
+.widget-preview-card__name {
+	font-size: 13px;
+	font-weight: 700;
+	color: #1f2937;
+}
+
+.widget-preview-card__desc {
+	margin-top: 3px;
+	font-size: 11px;
+	line-height: 1.35;
+	color: #6b7280;
+}
+
+.mock-line,
+.mock-value,
+.mock-bar {
+	position: absolute;
+	left: 10px;
+	border-radius: 999px;
+	background: #60a5fa;
+}
+
+.mock-line--short {
+	top: 9px;
+	width: 42px;
+	height: 5px;
+	opacity: 0.85;
+}
+
+.mock-value {
+	top: 23px;
+	width: 28px;
+	height: 12px;
+	background: #34d399;
+}
+
+.mock-bar {
+	right: 10px;
+	bottom: 9px;
+	height: 6px;
+	background: linear-gradient(90deg, #60a5fa 65%, #334155 65%);
+}
+
+.mock-bars {
+	position: absolute;
+	inset: 10px 14px 9px;
+	display: flex;
+	align-items: flex-end;
+	justify-content: space-between;
+}
+
+.mock-bars span {
+	width: 10px;
+	border-radius: 3px 3px 0 0;
+	background: #60a5fa;
+}
+
+.mock-line-chart {
+	position: absolute;
+	inset: 12px 10px;
+}
+
+.mock-line-chart span {
+	position: absolute;
+	width: 7px;
+	height: 7px;
+	border-radius: 50%;
+	background: #34d399;
+	box-shadow: 0 0 0 2px rgba(52, 211, 153, 0.18);
+}
+
+.mock-line-chart span:nth-child(1) {
+	left: 0;
+	bottom: 7px;
+}
+
+.mock-line-chart span:nth-child(2) {
+	left: 28%;
+	bottom: 22px;
+}
+
+.mock-line-chart span:nth-child(3) {
+	left: 58%;
+	bottom: 14px;
+}
+
+.mock-line-chart span:nth-child(4) {
+	right: 0;
+	bottom: 30px;
+}
+
+.mock-line-chart::before {
+	content: '';
+	position: absolute;
+	left: 4px;
+	right: 4px;
+	top: 19px;
+	height: 2px;
+	background: linear-gradient(105deg, transparent 0 18%, #34d399 18% 42%, transparent 42% 48%, #34d399 48% 72%, transparent 72% 78%, #34d399 78%);
+	transform: rotate(-9deg);
+}
+
+.mock-gauge {
+	position: absolute;
+	left: 50%;
+	top: 11px;
+	width: 42px;
+	height: 42px;
+	border: 7px solid #334155;
+	border-top-color: #34d399;
+	border-right-color: #60a5fa;
+	border-radius: 50%;
+	transform: translateX(-50%) rotate(35deg);
+}
+
+.mock-gauge-value {
+	position: absolute;
+	left: 50%;
+	bottom: 10px;
+	width: 22px;
+	height: 6px;
+	border-radius: 999px;
+	background: #e2e8f0;
+	transform: translateX(-50%);
+}
+
+.mock-funnel {
+	position: absolute;
+	inset: 9px 14px;
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	gap: 4px;
+}
+
+.mock-funnel span {
+	height: 9px;
+	border-radius: 2px;
+	background: #60a5fa;
+}
+
+.mock-funnel span:nth-child(1) {
+	width: 58px;
+}
+
+.mock-funnel span:nth-child(2) {
+	width: 42px;
+	background: #34d399;
+}
+
+.mock-funnel span:nth-child(3) {
+	width: 24px;
+	background: #fbbf24;
+}
+
+.mock-table {
+	display: grid;
+	grid-template-columns: repeat(3, 1fr);
+	gap: 4px;
+	padding: 10px;
+}
+
+.mock-table span {
+	height: 9px;
+	border-radius: 2px;
+	background: #60a5fa;
+	opacity: 0.85;
+}
+</style>

+ 6 - 4
Web/src/views/aidop/kanban/kpiMaster.vue

@@ -98,6 +98,7 @@
 										<el-radio :value="1">L1</el-radio>
 										<el-radio :value="2">L2</el-radio>
 										<el-radio :value="3">L3</el-radio>
+										<el-radio :value="4">L4</el-radio>
 									</el-radio-group>
 								</el-form-item>
 							</el-col>
@@ -271,7 +272,7 @@
 						<el-icon v-else><ele-CircleCloseFilled /></el-icon>
 						<span>{{ importResult.message }}</span>
 						<span v-if="importResult.detail" class="import-tab__result-detail">
-							(L1: {{ importResult.detail.l1 }} 条, L2: {{ importResult.detail.l2 }} 条, L3: {{ importResult.detail.l3 }} 条, {{ importResult.detail.days }} 天)
+							(L1: {{ importResult.detail.l1 }} 条, L2: {{ importResult.detail.l2 }} 条, L3: {{ importResult.detail.l3 }} 条, L4: {{ importResult.detail.l4 }} 条, {{ importResult.detail.days }} 天)
 						</span>
 					</div>
 				</div>
@@ -501,8 +502,8 @@ async function onAddChild(parent: KpiTreeNode) {
 		}
 		return;
 	}
-	if (parent.metricLevel >= 3) {
-		ElMessage.warning('L3 是最低层级,不能再添加子指标');
+	if (parent.metricLevel >= 4) {
+		ElMessage.warning('L4 是最低层级,不能再添加子指标');
 		return;
 	}
 	const { value: name } = await ElMessageBox.prompt(`在 "${parent.metricName}" 下添加 L${parent.metricLevel + 1} 指标`, '新增子指标', { confirmButtonText: '确定', cancelButtonText: '取消' });
@@ -539,7 +540,7 @@ function countChildren(node: KpiTreeNode): number {
 function allowDrop(dragging: any, drop: any, type: NodeDropType) {
 	if (dragging.data.metricLevel === 0 || drop.data.metricLevel === 0) return false;
 	if (type === 'inner') {
-		return drop.data.metricLevel < 3 && dragging.data.metricLevel > drop.data.metricLevel;
+		return drop.data.metricLevel < 4 && dragging.data.metricLevel > drop.data.metricLevel;
 	}
 	return dragging.data.metricLevel === drop.data.metricLevel;
 }
@@ -762,6 +763,7 @@ onBeforeUnmount(() => {
 		&--l1 { background: #409eff; }
 		&--l2 { background: #67c23a; }
 		&--l3 { background: #e6a23c; }
+		&--l4 { background: #f56c6c; }
 	}
 	&__name--mod {
 		font-size: 14px;

+ 20 - 1276
Web/src/views/aidop/kanban/s1.vue

@@ -1,1283 +1,27 @@
 <template>
-  <div class="s1-dashboard">
-    <!-- 顶部标题栏 -->
-    <div class="top-bar">
-      <div class="top-left">
-        <div class="page-title">
-          <el-icon :size="28"><Document /></el-icon>
-          <div>
-            <h1>产销协同指标看板</h1>
-            <div class="version-info">
-              <span>系统版本 V3.0.4</span>
-              <span class="status-dot"></span>
-              <span>状态:运行正常</span>
-            </div>
-          </div>
-        </div>
-      </div>
-      <div class="top-center">
-        <div class="search-box">
-          <el-input 
-            v-model="searchText" 
-            placeholder="搜索指标名称、部门 (如:工艺)..."
-            prefix-icon="Search"
-            clearable
-          />
-        </div>
-      </div>
-      <div class="top-right">
-        <div class="datetime">
-          <div class="date">{{ currentDate }}</div>
-          <div class="time">{{ currentTime }}</div>
-        </div>
-        <el-button type="primary" size="small">
-          <el-icon><Download /></el-icon>
-          导出实时快报
-        </el-button>
-        <el-button size="small">系统设置</el-button>
-      </div>
-    </div>
-
-    <!-- 模式切换栏 -->
-    <div class="mode-bar">
-      <div class="mode-left">
-        <el-button size="small" :type="activeMode === 'overview' ? 'primary' : ''">概览模式</el-button>
-        <el-button size="small" :type="activeMode === 'manufacturing' ? 'primary' : ''">制造侧监控</el-button>
-        <el-button size="small" :type="activeMode === 'sales' ? 'primary' : ''">销售侧分析</el-button>
-        <el-button size="small" :type="activeMode === 'supply' ? 'primary' : ''">供应链链动</el-button>
-      </div>
-      <div class="mode-right">
-        <span class="auto-refresh">自动轮播:</span>
-        <el-switch v-model="autoRefresh" />
-      </div>
-    </div>
-
-    <!-- 筛选栏 -->
-    <div class="filter-bar">
-      <div class="filter-left">
-        <span class="filter-label">部门视图</span>
-        <el-radio-group v-model="deptFilter" size="small">
-          <el-radio-button label="all">全部</el-radio-button>
-          <el-radio-button label="plan">主计划</el-radio-button>
-          <el-radio-button label="sales">销售</el-radio-button>
-          <el-radio-button label="design">设计</el-radio-button>
-          <el-radio-button label="tech">工艺</el-radio-button>
-        </el-radio-group>
-      </div>
-      <div class="filter-right">
-        <span class="filter-label">状态监控</span>
-        <el-radio-group v-model="statusFilter" size="small">
-          <el-radio-button label="all">全部</el-radio-button>
-          <el-radio-button label="optimal">最优</el-radio-button>
-          <el-radio-button label="standby">待机</el-radio-button>
-          <el-radio-button label="critical">严重</el-radio-button>
-        </el-radio-group>
-        <div class="showing-info">
-          展示:8 / 8 张卡片
-          <el-button link type="primary" size="small">
-            <el-icon><Refresh /></el-icon>
-            重置
-          </el-button>
-        </div>
-      </div>
-    </div>
-
-    <!-- 业务筛选:产销协同以订单/客户维度为主 -->
-    <DetailQueryBar
-      dark
-      band-title="基础查询:日期、产品、订单号、产线"
-      @query="onBizQuery"
-      @reset="onBizReset"
-    >
-      <SmartOpsBaseQueryFields v-model="bizQuery" compact />
-      <el-form-item label="客户">
-        <el-input v-model="bizQuery.customer" placeholder="客户名称/编码" clearable style="width: 160px" />
-      </el-form-item>
-    </DetailQueryBar>
-
-    <!-- 主内容区 -->
-    <div class="main-content">
-      <!-- 第一行:L1 指标卡(由"运营指标建模"动态驱动) -->
-      <div class="row-1">
-        <!-- Slot 0 / Slot 1 / Slot 2:普通指标卡(动态 L1 前三位) -->
-        <div
-          v-for="(slot, idx) in l1Slots.slice(0, 3)"
-          :key="`l1-slot-${idx}`"
-          class="card metric-card"
-        >
-          <template v-if="slot">
-            <div class="card-header">
-              <el-tooltip v-if="slot.formulaText" placement="top" :show-after="200" :content="slot.formulaText">
-                <span class="card-title is-formula">{{ slot.displayName || slot.metricCode }}</span>
-              </el-tooltip>
-              <span v-else class="card-title">{{ slot.displayName || slot.metricCode }}</span>
-              <el-tag size="small" :type="kpiTagType(slot.achievementLevel)">{{ kpiTagText(slot.achievementLevel) }}</el-tag>
-            </div>
-            <div class="metric-value">
-              <span class="value large">{{ fmtKpiValue(slot.currentValue) }}</span>
-              <span v-if="slot.unit" class="unit">{{ slot.unit }}</span>
-              <span class="trend" :class="kpiTrendClass(slot.achievementLevel)">
-                {{ slot.gapLabel || (slot.targetValue != null ? `目标 ${fmtKpiValue(slot.targetValue)}${slot.unit || ''}` : '') }}
-              </span>
-            </div>
-            <div v-if="slot.targetValue != null" class="progress-section">
-              <div class="progress-label">
-                <span>目标达成</span>
-                <span>{{ fmtKpiValue(slot.targetValue) }}{{ slot.unit || '' }} 目标</span>
-              </div>
-              <div class="progress-bar">
-                <div
-                  class="progress-fill"
-                  :style="{
-                    width: `${Math.max(0, Math.min(100, Number(slot.currentValue != null && slot.targetValue ? (Number(slot.currentValue) / Number(slot.targetValue)) * 100 : 0) || 0))}%`,
-                  }"
-                ></div>
-              </div>
-            </div>
-          </template>
-          <template v-else>
-            <div class="card-header">
-              <span class="card-title is-placeholder">(未配置 L1)</span>
-            </div>
-            <div class="metric-value">
-              <span class="value large">—</span>
-            </div>
-            <div class="placeholder-hint">请到「运营指标建模」为 {{ MODULE_CODE }} 添加 L1</div>
-          </template>
-        </div>
-
-        <!-- Slot 3:评审队列负荷 仪表盘(保留原形态;值取自动态 L1 第 4 位达成率) -->
-        <div class="card gauge-card">
-          <div class="card-header">
-            <el-tooltip v-if="l1Slots[3]?.formulaText" placement="top" :show-after="200" :content="l1Slots[3].formulaText">
-              <span class="card-title is-formula">{{ l1Slots[3]?.displayName || '评审队列负荷' }}</span>
-            </el-tooltip>
-            <span v-else class="card-title">{{ l1Slots[3]?.displayName || '评审队列负荷' }}</span>
-          </div>
-          <div id="gauge-pressure" class="gauge-chart"></div>
-          <div class="gauge-footer">
-            <div class="label">{{ l1Slots[3] ? '当前值' : '待评审订单数' }}</div>
-            <div class="value">
-              {{ l1Slots[3] ? fmtKpiValue(l1Slots[3].currentValue) : 128 }}
-              <span class="unit">{{ l1Slots[3]?.unit || '单' }}</span>
-            </div>
-          </div>
-        </div>
-      </div>
-
-      <!-- 第二行 -->
-      <div class="row-2">
-        <!-- 评审满足率与周期趋势 -->
-        <div class="card large-chart-card">
-          <div class="card-header">
-            <div>
-              <span class="card-title">评审满足率与周期趋势(采购执行联动)</span>
-              <div class="subtitle">实时数据流</div>
-            </div>
-            <div class="legend">
-              <span class="legend-item">
-                <span class="dot" style="background: #60a5fa"></span>
-                满足率
-              </span>
-              <span class="legend-item">
-                <span class="dot" style="background: #34d399"></span>
-                均值线
-              </span>
-            </div>
-          </div>
-          <div id="chart-trend" class="main-chart"></div>
-        </div>
-
-        <!-- 异常流 -->
-        <div class="card anomaly-card">
-          <div class="card-header">
-            <span class="card-title">
-              <span class="live-dot"></span>
-              异常实时流
-            </span>
-            <span class="buffer-info">缓冲区:1024KB</span>
-          </div>
-          <div class="anomaly-list">
-            <div v-for="(item, idx) in anomalyItems" :key="`${item.time}-${idx}`" class="anomaly-item" :class="item.levelClass">
-              <div class="anomaly-header">
-                <span class="tag">{{ item.tag }}</span>
-                <span class="time">{{ item.time }}</span>
-              </div>
-              <div class="anomaly-message">{{ item.message }}</div>
-              <div v-if="item.source" class="anomaly-source">{{ item.source }}</div>
-            </div>
-          </div>
-          <div class="command-input">
-            <el-input 
-              v-model="commandText" 
-              placeholder="输入指令以响应..."
-              size="small"
-            >
-              <template #append>
-                <el-button type="primary">执行</el-button>
-              </template>
-            </el-input>
-          </div>
-        </div>
-      </div>
-
-      <!-- 第三行 -->
-      <div class="row-3">
-        <!-- 异常状态分布 -->
-        <div class="card chart-card">
-          <div class="card-header">
-            <span class="card-title">异常状态分布(物料仓储)</span>
-          </div>
-          <div id="chart-donut" class="donut-chart"></div>
-        </div>
-
-        <!-- 人效热度监控 -->
-        <div class="card chart-card">
-          <div class="card-header">
-            <span class="card-title">人效热度监控(生产执行)</span>
-          </div>
-          <div id="chart-bar" class="bar-chart"></div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 底部版权信息 -->
-    <div class="footer">
-      <div class="footer-left">数据流已加密(AES-256-GCM)</div>
-      <div class="footer-right">版权所有 © 2026 核心智能运营 · 系统运行正常</div>
-    </div>
-  </div>
+	<DynamicModuleDashboard
+		module-code="S1"
+		title="S1 产销协同看板"
+		:extra-query-init="extraQueryInit"
+		:extra-query-mapper="mapExtraQuery"
+	>
+		<template #extra-fields="{ query }">
+			<el-form-item label="客户">
+				<el-input v-model="query.customer" placeholder="客户名称/编码" clearable style="width: 160px" />
+			</el-form-item>
+		</template>
+	</DynamicModuleDashboard>
 </template>
 
-<script setup>
-import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
-import { useRouter } from 'vue-router'
-import { ElMessage } from 'element-plus'
-import { Document, Search, Download, Refresh } from '@element-plus/icons-vue'
-import DetailQueryBar from './components/DetailQueryBar.vue'
-import SmartOpsBaseQueryFields from './components/SmartOpsBaseQueryFields.vue'
-import {
-  emptySmartOpsBaseQuery,
-  blobMatchesSmartOpsBase,
-  textMatches,
-  summarizeSmartOpsBaseQuery,
-  baseQueryToApiParams,
-} from './utils/smartOpsBaseQuery'
-import * as echarts from 'echarts'
-import { homeS1, loadHomeModuleMetrics } from './data/homeModulesSync'
-import { fetchModuleDetail, fetchHomeGrid, fetchDetailKpis } from '../api/kanbanData'
-import { AIDOP_LAYOUT_SAVED } from './utils/s4LayoutEvents'
-
-const MODULE_CODE = 'S1'
-
-const router = useRouter()
-
-const searchText = ref('')
-const commandText = ref('')
-const currentTime = ref('')
-const currentDate = ref('')
-const autoRefresh = ref(true)
-const activeMode = ref('overview')
-const deptFilter = ref('all')
-const statusFilter = ref('all')
-const trendXAxis = ref(['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8'])
-const barXAxis = ref(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'])
-const barSeries = ref([65, 78, 52, 92, 75, 68])
-
-const bizQuery = ref({
-  ...emptySmartOpsBaseQuery(),
-  customer: '',
-})
-
-const l1Items = ref([])
-const l2Items = ref([])
+<script setup lang="ts" name="aidopKanbanS1">
+import DynamicModuleDashboard from './components/DynamicModuleDashboard.vue';
 
-const l1Slots = computed(() => {
-  const arr = Array.isArray(l1Items.value) ? l1Items.value : []
-  return [0, 1, 2, 3].map((i) => arr[i] || null)
-})
+const extraQueryInit = {
+	customer: '',
+};
 
-function fmtKpiValue(v) {
-  if (v === null || v === undefined || v === '') return '—'
-  const n = Number(v)
-  if (!Number.isFinite(n)) return String(v)
-  if (Math.abs(n) >= 100) return n.toFixed(0)
-  if (Math.abs(n) >= 10) return n.toFixed(1)
-  return n.toFixed(2)
+function mapExtraQuery(query: Record<string, string>) {
+	const customer = String(query.customer || '').trim();
+	return customer ? { customer } : {};
 }
-
-function kpiTagType(level) {
-  const lv = String(level || '').toLowerCase()
-  if (lv === 'red') return 'danger'
-  if (lv === 'yellow') return 'warning'
-  if (lv === 'green') return 'success'
-  return 'info'
-}
-
-function kpiTagText(level) {
-  const lv = String(level || '').toLowerCase()
-  if (lv === 'red') return '未达标'
-  if (lv === 'yellow') return '待改善'
-  if (lv === 'green') return '达标'
-  return '—'
-}
-
-function kpiTrendClass(level) {
-  const lv = String(level || '').toLowerCase()
-  if (lv === 'red') return 'down'
-  if (lv === 'green') return 'up'
-  return ''
-}
-
-function buildDonutFromList(list) {
-  const c = list.filter((x) => x.levelClass === 'critical').length
-  const w = list.filter((x) => x.levelClass === 'warning').length
-  const i = list.filter((x) => x.levelClass === 'info').length
-  const standby = Math.max(0, list.length - c - w - i)
-  return [
-    { value: c, name: '严重', itemStyle: { color: '#f87171' } },
-    { value: w, name: '警告', itemStyle: { color: '#fbbf24' } },
-    { value: i, name: '正常', itemStyle: { color: '#34d399' } },
-    { value: standby, name: '待机', itemStyle: { color: '#60a5fa' } },
-  ]
-}
-
-const anomalyItemsAll = ref([])
-
-const anomalyItems = computed(() =>
-  anomalyItemsAll.value.filter((x) => {
-    const blob = `${x.message} ${x.tag} ${x.source}`
-    if (!blobMatchesSmartOpsBase(bizQuery.value, blob)) return false
-    if (!textMatches(bizQuery.value.customer, blob)) return false
-    return true
-  })
-)
-
-const donutSeries = ref(buildDonutFromList([]))
-
-watch(
-  anomalyItems,
-  (list) => {
-    donutSeries.value = buildDonutFromList(list)
-    nextTick(() => donutChart?.setOption({ series: [{ data: donutSeries.value }] }))
-  },
-  { deep: true }
-)
-
-function bizQueryExtra() {
-  return { ...baseQueryToApiParams(bizQuery.value) }
-}
-
-async function loadDynamicKpis() {
-  const extra = bizQueryExtra()
-  const [grid, detail] = await Promise.all([
-    fetchHomeGrid(MODULE_CODE, 1, extra),
-    fetchDetailKpis(MODULE_CODE, 1, undefined, extra),
-  ])
-  l1Items.value = Array.isArray(grid?.items) ? grid.items : []
-  l2Items.value = Array.isArray(detail) ? detail : []
-  applyDynamicChartData()
-}
-
-function applyDynamicChartData() {
-  const l2 = l2Items.value
-  if (l2.length > 0) {
-    const slice = l2.slice(0, 10)
-    barXAxis.value = slice.map((x, i) => x.displayName || x.metricCode || `N${i + 1}`)
-    barSeries.value = slice.map((x) => {
-      const n = Number(x.currentValue)
-      return Number.isFinite(n) ? n : 0
-    })
-    nextTick(() => barChart?.setOption({ xAxis: { data: barXAxis.value }, series: [{ data: barSeries.value }] }))
-  }
-}
-
-function onBizQuery() {
-  loadDynamicKpis()
-  ElMessage.success(`已应用筛选(${summarizeSmartOpsBaseQuery(bizQuery.value)})`)
-}
-
-function onBizReset() {
-  bizQuery.value = { ...emptySmartOpsBaseQuery(), customer: '' }
-  loadDynamicKpis()
-  ElMessage.info('已重置业务筛选')
-}
-
-function onLayoutSaved(e) {
-  const mc = String(e?.detail?.moduleCode || '').toUpperCase()
-  if (!mc || mc === MODULE_CODE) loadDynamicKpis()
-}
-
-let gaugeChart = null
-let trendChart = null
-let donutChart = null
-let barChart = null
-
-const updateTime = () => {
-  const now = new Date()
-  currentTime.value = now.toLocaleString('zh-CN', {
-    year: 'numeric',
-    month: '2-digit',
-    day: '2-digit',
-    hour: '2-digit',
-    minute: '2-digit',
-    second: '2-digit',
-    hour12: false
-  })
-  currentDate.value = now.toLocaleDateString('zh-CN')
-}
-
-// 初始化评审队列负荷仪表
-const initGaugeChart = () => {
-  const chartDom = document.getElementById('gauge-pressure')
-  if (!chartDom) return
-  
-  gaugeChart = echarts.init(chartDom)
-  const option = {
-    series: [{
-      type: 'gauge',
-      startAngle: 180,
-      endAngle: 0,
-      min: 0,
-      max: 100,
-      radius: '90%',
-      center: ['50%', '60%'],
-      itemStyle: {
-        color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
-          { offset: 0, color: '#34d399' },
-          { offset: 0.5, color: '#fbbf24' },
-          { offset: 1, color: '#f87171' }
-        ])
-      },
-      progress: {
-        show: true,
-        width: 12,
-        roundCap: true
-      },
-      pointer: { show: false },
-      axisLine: {
-        lineStyle: {
-          width: 12,
-          color: [[1, '#1e293b']]
-        }
-      },
-      axisTick: { show: false },
-      splitLine: { show: false },
-      axisLabel: {
-        show: true,
-        color: '#64748b',
-        fontSize: 10,
-        distance: -15
-      },
-      title: { show: false },
-      detail: {
-        valueAnimation: true,
-        offsetCenter: [0, '10%'],
-        fontSize: 24,
-        fontWeight: 'bold',
-        color: '#e2e8f0',
-        formatter: '{value}%'
-      },
-      data: [{ value: computeGaugeValue() }]
-    }]
-  }
-  gaugeChart.setOption(option)
-}
-
-function computeGaugeValue() {
-  const s = l1Slots.value[3]
-  if (s && s.currentValue != null && s.targetValue != null && Number(s.targetValue) !== 0) {
-    const pct = (Number(s.currentValue) / Number(s.targetValue)) * 100
-    if (Number.isFinite(pct)) return Math.max(0, Math.min(100, Math.round(pct)))
-  }
-  return homeS1.systemPressureGaugePct
-}
-
-watch(l1Slots, () => {
-  nextTick(() => gaugeChart?.setOption({ series: [{ data: [{ value: computeGaugeValue() }] }] }))
-}, { deep: true })
-
-// 初始化趋势图
-const initTrendChart = () => {
-  const chartDom = document.getElementById('chart-trend')
-  if (!chartDom) return
-  
-  trendChart = echarts.init(chartDom)
-  const option = {
-    tooltip: {
-      trigger: 'axis',
-      backgroundColor: 'rgba(15, 23, 42, 0.95)',
-      borderColor: '#334155',
-      textStyle: { color: '#e2e8f0' },
-      axisPointer: { type: 'cross' }
-    },
-    grid: {
-      left: '3%',
-      right: '4%',
-      bottom: '10%',
-      top: '15%',
-      containLabel: true
-    },
-    xAxis: {
-      type: 'category',
-      boundaryGap: false,
-      data: trendXAxis.value,
-      axisLine: { lineStyle: { color: '#334155' } },
-      axisLabel: { color: '#64748b' }
-    },
-    yAxis: {
-      type: 'value',
-      min: 70,
-      max: 100,
-      splitLine: {
-        lineStyle: {
-          color: '#1e293b',
-          type: 'solid'
-        }
-      },
-      axisLabel: { color: '#64748b' }
-    },
-    series: [
-      {
-        name: '满足率',
-        type: 'line',
-        smooth: true,
-        symbol: 'circle',
-        symbolSize: 6,
-        itemStyle: {
-          color: '#60a5fa',
-          borderColor: '#1e293b',
-          borderWidth: 2
-        },
-        lineStyle: {
-          color: '#60a5fa',
-          width: 3
-        },
-        areaStyle: {
-          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-            { offset: 0, color: 'rgba(96, 165, 250, 0.3)' },
-            { offset: 1, color: 'rgba(96, 165, 250, 0)' }
-          ])
-        },
-        data: homeS1.trendSatisfaction
-      },
-      {
-        name: '均值线',
-        type: 'line',
-        smooth: true,
-        symbol: 'none',
-        lineStyle: {
-          color: '#34d399',
-          width: 2,
-          type: 'dashed'
-        },
-        data: Array(8).fill(homeS1.trendMeanLine)
-      }
-    ]
-  }
-  trendChart.setOption(option)
-}
-
-// 初始化环形图
-const initDonutChart = () => {
-  const chartDom = document.getElementById('chart-donut')
-  if (!chartDom) return
-  
-  donutChart = echarts.init(chartDom)
-  const option = {
-    tooltip: {
-      trigger: 'item',
-      backgroundColor: 'rgba(15, 23, 42, 0.95)',
-      borderColor: '#334155',
-      textStyle: { color: '#e2e8f0' }
-    },
-    series: [{
-      name: '异常状态',
-      type: 'pie',
-      radius: ['50%', '70%'],
-      center: ['50%', '50%'],
-      avoidLabelOverlap: false,
-      label: {
-        show: false
-      },
-      emphasis: {
-        label: {
-          show: true,
-          fontSize: 14,
-          fontWeight: 'bold',
-          color: '#e2e8f0'
-        }
-      },
-      labelLine: { show: false },
-      data: donutSeries.value
-    }]
-  }
-  donutChart.setOption(option)
-}
-
-// 初始化柱状图
-const initBarChart = () => {
-  const chartDom = document.getElementById('chart-bar')
-  if (!chartDom) return
-  
-  barChart = echarts.init(chartDom)
-  const option = {
-    tooltip: {
-      trigger: 'axis',
-      backgroundColor: 'rgba(15, 23, 42, 0.95)',
-      borderColor: '#334155',
-      textStyle: { color: '#e2e8f0' }
-    },
-    grid: {
-      left: '3%',
-      right: '4%',
-      bottom: '15%',
-      top: '10%',
-      containLabel: true
-    },
-    xAxis: {
-      type: 'category',
-      data: barXAxis.value,
-      axisLine: { lineStyle: { color: '#334155' } },
-      axisLabel: { color: '#64748b' }
-    },
-    yAxis: {
-      type: 'value',
-      show: false
-    },
-    series: [{
-      data: barSeries.value,
-      type: 'bar',
-      barWidth: '60%',
-      itemStyle: {
-        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-          { offset: 0, color: '#60a5fa' },
-          { offset: 1, color: '#3b82f6' }
-        ]),
-        borderRadius: [4, 4, 0, 0]
-      },
-      showBackground: true,
-      backgroundStyle: {
-        color: 'rgba(51, 65, 85, 0.3)',
-        borderRadius: [4, 4, 0, 0]
-      }
-    }]
-  }
-  barChart.setOption(option)
-}
-
-const initCharts = () => {
-  nextTick(() => {
-    initGaugeChart()
-    initTrendChart()
-    initDonutChart()
-    initBarChart()
-    
-    window.addEventListener('resize', () => {
-      gaugeChart?.resize()
-      trendChart?.resize()
-      donutChart?.resize()
-      barChart?.resize()
-    })
-  })
-}
-
-onMounted(async () => {
-  await loadHomeModuleMetrics()
-  const detail = await fetchModuleDetail(MODULE_CODE)
-  anomalyItemsAll.value = (detail.alerts ?? []).map((x) => ({
-    tag: `[${String(x.level ?? 'info').toUpperCase()}]`,
-    time: x.time ?? '--:--:--',
-    message: x.message ?? '异常告警',
-    source: `Source: Ai-DOP / Module: ${MODULE_CODE}`,
-    levelClass: ['critical', 'high'].includes(String(x.level)) ? 'critical' : String(x.level) === 'warning' ? 'warning' : 'info'
-  }))
-  await loadDynamicKpis()
-  updateTime()
-  setInterval(updateTime, 1000)
-  initCharts()
-  if (typeof window !== 'undefined') {
-    window.addEventListener(AIDOP_LAYOUT_SAVED, onLayoutSaved)
-  }
-})
-
-onUnmounted(() => {
-  if (typeof window !== 'undefined') {
-    window.removeEventListener(AIDOP_LAYOUT_SAVED, onLayoutSaved)
-  }
-})
 </script>
-
-<style scoped>
-.s1-dashboard {
-  box-sizing: border-box;
-  flex: 1;
-  min-height: 0;
-  max-height: 100%;
-  overflow-x: hidden;
-  overflow-y: auto;
-  -webkit-overflow-scrolling: touch;
-  background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
-  padding: 15px;
-  padding-bottom: 24px;
-  color: #e2e8f0;
-}
-
-/* 顶部标题栏 */
-.top-bar {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  background: rgba(15, 23, 42, 0.8);
-  padding: 15px 20px;
-  border-radius: 8px;
-  margin-bottom: 15px;
-  border: 1px solid #1e293b;
-}
-
-.top-left {
-  display: flex;
-  align-items: center;
-  gap: 15px;
-}
-
-.page-title {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  color: #60a5fa;
-}
-
-.page-title h1 {
-  margin: 0;
-  font-size: 22px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.version-info {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  font-size: 11px;
-  color: #64748b;
-  margin-top: 4px;
-}
-
-.status-dot {
-  width: 6px;
-  height: 6px;
-  border-radius: 50%;
-  background: #34d399;
-}
-
-.top-center {
-  flex: 1;
-  max-width: 500px;
-  margin: 0 30px;
-}
-
-.search-box :deep(.el-input__wrapper) {
-  background: rgba(30, 41, 59, 0.6);
-  border: 1px solid #334155;
-}
-
-.search-box :deep(.el-input__inner) {
-  color: #e2e8f0;
-}
-
-.top-right {
-  display: flex;
-  align-items: center;
-  gap: 15px;
-}
-
-.datetime {
-  text-align: right;
-  margin-right: 10px;
-}
-
-.datetime .date {
-  font-size: 13px;
-  color: #93c5fd;
-  font-weight: 600;
-}
-
-.datetime .time {
-  font-size: 18px;
-  color: #60a5fa;
-  font-weight: 700;
-  font-family: 'Courier New', monospace;
-}
-
-/* 模式切换栏 */
-.mode-bar {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  background: rgba(30, 41, 59, 0.6);
-  padding: 10px 15px;
-  border-radius: 6px;
-  margin-bottom: 15px;
-  border: 1px solid #334155;
-}
-
-.mode-left {
-  display: flex;
-  gap: 10px;
-}
-
-.mode-right {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  color: #94a3b8;
-  font-size: 13px;
-}
-
-/* 筛选栏 */
-.filter-bar {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  background: rgba(30, 41, 59, 0.4);
-  padding: 12px 15px;
-  border-radius: 6px;
-  margin-bottom: 15px;
-  border: 1px solid #1e293b;
-}
-
-.filter-left, .filter-right {
-  display: flex;
-  align-items: center;
-  gap: 15px;
-}
-
-.filter-label {
-  font-size: 12px;
-  color: #64748b;
-  font-weight: 600;
-}
-
-.showing-info {
-  font-size: 12px;
-  color: #64748b;
-  margin-left: 20px;
-  display: flex;
-  align-items: center;
-  gap: 10px;
-}
-
-/* 主内容区 */
-.main-content {
-  display: flex;
-  flex-direction: column;
-  gap: 15px;
-}
-
-.row-1 {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 15px;
-}
-
-.row-2 {
-  display: grid;
-  grid-template-columns: 2fr 1fr;
-  gap: 15px;
-}
-
-.row-3 {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  gap: 15px;
-}
-
-/* 卡片通用样式 */
-.card {
-  background: rgba(30, 41, 59, 0.6);
-  border-radius: 8px;
-  padding: 15px;
-  border: 1px solid #334155;
-}
-
-.card-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 15px;
-}
-
-.card-title {
-  font-size: 13px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.card-title.is-formula {
-  cursor: help;
-  border-bottom: 1px dashed rgba(148, 163, 184, 0.5);
-}
-
-.card-title.is-placeholder {
-  color: #64748b;
-  font-weight: 500;
-}
-
-.placeholder-hint {
-  margin-top: 10px;
-  font-size: 11px;
-  color: #64748b;
-  line-height: 1.5;
-}
-
-.subtitle {
-  font-size: 10px;
-  color: #64748b;
-  margin-top: 4px;
-}
-
-/* 指标卡片 */
-.metric-card .metric-value {
-  display: flex;
-  align-items: baseline;
-  gap: 8px;
-  margin-bottom: 15px;
-}
-
-.metric-card .value {
-  font-size: 36px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.metric-card .value.large {
-  font-size: 48px;
-}
-
-.metric-card .unit {
-  font-size: 16px;
-  color: #64748b;
-}
-
-.trend {
-  font-size: 13px;
-  font-weight: 700;
-}
-
-.trend.up {
-  color: #34d399;
-}
-
-.trend.down {
-  color: #f87171;
-}
-
-.progress-section {
-  margin-top: 10px;
-}
-
-.progress-label {
-  display: flex;
-  justify-content: space-between;
-  font-size: 11px;
-  color: #94a3b8;
-  margin-bottom: 6px;
-}
-
-.progress-bar {
-  height: 6px;
-  background: #1e293b;
-  border-radius: 3px;
-  overflow: hidden;
-}
-
-.progress-fill {
-  height: 100%;
-  background: linear-gradient(90deg, #60a5fa, #3b82f6);
-  border-radius: 3px;
-}
-
-.sub-metrics {
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  gap: 10px;
-  margin-top: 15px;
-}
-
-.sub-metric {
-  background: rgba(0, 0, 0, 0.2);
-  padding: 10px;
-  border-radius: 4px;
-}
-
-.sub-metric .label {
-  font-size: 11px;
-  color: #64748b;
-  margin-bottom: 4px;
-}
-
-.sub-metric .value {
-  font-size: 16px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-/* 约束列表 */
-.constraint-list {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-}
-
-.constraint-item {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  padding: 8px;
-  background: rgba(0, 0, 0, 0.2);
-  border-radius: 4px;
-}
-
-.constraint-item .dot {
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
-  background: #34d399;
-}
-
-.constraint-item .dot.warning {
-  background: #fbbf24;
-}
-
-.constraint-item .label {
-  flex: 1;
-  font-size: 12px;
-  color: #94a3b8;
-}
-
-.constraint-item .value {
-  font-size: 14px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.constraint-item .value.warning {
-  color: #fbbf24;
-}
-
-/* 仪表盘卡片 */
-.gauge-card {
-  text-align: center;
-}
-
-.gauge-chart {
-  width: 100%;
-  height: 150px;
-}
-
-.gauge-footer {
-  margin-top: 10px;
-}
-
-.gauge-footer .label {
-  font-size: 11px;
-  color: #64748b;
-  margin-bottom: 4px;
-}
-
-.gauge-footer .value {
-  font-size: 20px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.gauge-footer .unit {
-  font-size: 12px;
-  color: #64748b;
-}
-
-/* 大图表卡片 */
-.large-chart-card .main-chart {
-  width: 100%;
-  height: 350px;
-}
-
-.legend {
-  display: flex;
-  gap: 15px;
-  font-size: 12px;
-  color: #64748b;
-}
-
-.legend-item {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-}
-
-.legend-item .dot {
-  width: 10px;
-  height: 10px;
-  border-radius: 50%;
-}
-
-/* 异常流卡片 */
-.anomaly-card {
-  display: flex;
-  flex-direction: column;
-}
-
-.anomaly-card .card-header {
-  margin-bottom: 12px;
-}
-
-.anomaly-card .card-title {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-
-.live-dot {
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
-  background: #ef4444;
-  animation: pulse 2s infinite;
-}
-
-@keyframes pulse {
-  0%, 100% { opacity: 1; }
-  50% { opacity: 0.5; }
-}
-
-.buffer-info {
-  font-size: 11px;
-  color: #64748b;
-}
-
-.anomaly-list {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-  overflow-y: auto;
-  max-height: 400px;
-  margin-bottom: 12px;
-}
-
-.anomaly-item {
-  padding: 10px;
-  background: rgba(0, 0, 0, 0.2);
-  border-radius: 4px;
-  border-left: 3px solid;
-}
-
-.anomaly-item.critical {
-  border-color: #ef4444;
-  background: rgba(239, 68, 68, 0.1);
-}
-
-.anomaly-item.warning {
-  border-color: #f59e0b;
-  background: rgba(245, 158, 11, 0.1);
-}
-
-.anomaly-item.info {
-  border-color: #3b82f6;
-  background: rgba(59, 130, 246, 0.1);
-}
-
-.anomaly-item.success {
-  border-color: #10b981;
-  background: rgba(16, 185, 129, 0.1);
-}
-
-.anomaly-item.stall {
-  border-color: #8b5cf6;
-  background: rgba(139, 92, 246, 0.1);
-}
-
-.anomaly-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 6px;
-}
-
-.anomaly-header .tag {
-  font-size: 10px;
-  font-weight: 700;
-  padding: 2px 6px;
-  border-radius: 3px;
-}
-
-.anomaly-item.critical .tag { color: #ef4444; background: rgba(239, 68, 68, 0.2); }
-.anomaly-item.warning .tag { color: #f59e0b; background: rgba(245, 158, 11, 0.2); }
-.anomaly-item.info .tag { color: #3b82f6; background: rgba(59, 130, 246, 0.2); }
-.anomaly-item.success .tag { color: #10b981; background: rgba(16, 185, 129, 0.2); }
-.anomaly-item.stall .tag { color: #8b5cf6; background: rgba(139, 92, 246, 0.2); }
-
-.anomaly-header .time {
-  font-size: 11px;
-  color: #64748b;
-}
-
-.anomaly-message {
-  font-size: 12px;
-  color: #e2e8f0;
-  margin-bottom: 4px;
-}
-
-.anomaly-source {
-  font-size: 10px;
-  color: #64748b;
-}
-
-.command-input {
-  margin-top: 10px;
-}
-
-/* 图表卡片 */
-.chart-card .donut-chart,
-.chart-card .bar-chart {
-  width: 100%;
-  height: 200px;
-}
-
-/* 底部版权信息 */
-.footer {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-top: 20px;
-  padding-top: 15px;
-  border-top: 1px solid #1e293b;
-  font-size: 10px;
-  color: #475569;
-}
-
-/* Element Plus 覆盖 */
-:deep(.el-radio-button__inner) {
-  background: rgba(30, 41, 59, 0.6);
-  border: 1px solid #334155;
-  color: #94a3b8;
-}
-
-:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
-  background: linear-gradient(135deg, #3b82f6, #2563eb);
-  border-color: #3b82f6;
-  color: white;
-}
-
-:deep(.el-button--primary) {
-  background: linear-gradient(135deg, #3b82f6, #2563eb);
-  border: none;
-}
-
-:deep(.el-tag--success) {
-  background: rgba(16, 185, 129, 0.2);
-  border-color: rgba(16, 185, 129, 0.3);
-  color: #10b981;
-}
-</style>

+ 1080 - 0
doc/plan/动态运营详情看板与指标改善闭环方案.md

@@ -0,0 +1,1080 @@
+# 动态运营详情看板与指标改善闭环方案
+
+> 版本:v0.1  
+> 日期:2026-04-26  
+> 范围:S1-S9 详情看板,S8 异常监控暂按现有独立模型保留  
+> 重点:动态配置、下钻分析、自动诊断、基于审批流的改善闭环、测试验证
+
+## 1. 背景与目标
+
+当前智慧运营九宫格已经能进入各模块详情页,运营指标主数据也已经沉淀了指标定义、层级、公式、阈值、单位、优劣方向等信息。后续详情看板不能只展示静态页面或硬编码图表,需要形成一套可配置、可下钻、可诊断、可推动改善的动态运营分析能力。
+
+本方案目标:
+
+- 以 `运营指标主数据` 作为指标口径源,决定“看什么、怎么算、阈值是什么”。
+- 以动态看板配置决定“怎么组织页面、用什么组件、绑定哪些指标”。
+- 以统一筛选上下文驱动各组件、图表、下钻明细同步刷新。
+- 以红绿灯机制突出风险,超过红色阈值时支持闪烁预警。
+- 以 L1 → L2 → L3 → 明细单据的链路自动诊断主要问题。
+- 以现有审批流承载改善任务,形成“发现问题、制定计划、执行改善、验证效果”的闭环。
+
+## 2. 总体架构
+
+```mermaid
+flowchart TD
+  KpiMaster["运营指标主数据"] --> DashboardConfig["动态看板配置"]
+  KpiMaster --> DiagnosisEngine["自动诊断引擎"]
+  KpiValues["KPI 当前值/趋势值"] --> DashboardApi["统一看板 API"]
+  DashboardConfig --> DashboardApi
+  DrilldownData["业务明细/卡点数据"] --> DrilldownApi["下钻分析 API"]
+  DashboardApi --> Renderer["前端动态渲染器"]
+  DrilldownApi --> Renderer
+  DiagnosisEngine --> ImprovementLoop["指标改善闭环组件"]
+  ImprovementLoop --> ApprovalFlow["现有审批流插件"]
+  ApprovalFlow --> TodoCenter["审批中心/待办/通知"]
+  ApprovalFlow --> Verification["有效性验证"]
+  Verification --> ImprovementLoop
+```
+
+职责边界:
+
+- `运营指标主数据`:指标定义、层级、公式、阈值、单位、责任部门、数据来源。
+- `动态看板配置`:页面模式、Tab、区域、组件类型、指标绑定、下钻配置、样式配置。
+- `看板数据服务`:聚合当前值、目标值、状态、趋势、图表数据。
+- `下钻分析服务`:按指标和筛选条件返回关键环节、单据、负责人、卡住时长。
+- `自动诊断服务`:定位未达标指标路径,生成目标现状和主要问题。
+- `改善闭环服务`:创建改善单,提交审批流,记录基线、目标、动作和验证结果。
+- `审批流插件`:负责任务流转、责任人待办、审批记录、通知、超时和升级。
+
+## 3. 页面组织模式
+
+详情看板需要支持多种组织方式,并且可配置。
+
+### 3.1 普通 Tab 模式
+
+适合 S1 这类指标较多的模块。
+
+配置项:
+
+- `navigationMode = tabs_by_l1`
+- `tabSource = l1_metrics`
+- `defaultTabMetricCode`
+- `tabVisibleMetricCodes`
+
+表现:
+
+- 按 L1 指标自动生成 Tab。
+- 每个 Tab 下展示该 L1 相关的概览、趋势、下钻、诊断。
+
+### 3.2 L1 指标卡片 Tab 模式
+
+适合 S1 这类既需要突出核心指标,又需要按 L1 切换下方 L2/图表内容的模块。
+
+配置项:
+
+- `navigationMode = l1_cards`
+- `tabSource = l1_metrics`
+- `defaultTabMetricCode`
+
+表现:
+
+- 顶部“核心指标”区展示所有 L1 指标卡片。
+- 每张 L1 卡片展示指标名称、当前值、单位、目标值、偏差、红黄绿状态。
+- 点击某张 L1 卡片后,该卡片高亮,下方采用两层展示:上层是该 L1 对应的 L2 指标卡片,下层是趋势图和分析图表。
+- L2 指标卡片展示指标名称、当前值、目标值、单位、偏差和达标状态;图表区只负责展示趋势、对比、漏斗、热力图等分析内容。
+- 红色超限的 L1 卡片支持闪烁预警。
+- S1 推荐默认启用此模式。
+
+### 3.3 平铺模式
+
+适合指标少、页面内容较轻的模块。
+
+配置项:
+
+- `navigationMode = single_page`
+- `sectionLayout = grid`
+
+表现:
+
+- 所有区块按页面顺序直接呈现。
+- 可用于 S9 或后续指标较少的模块。
+
+### 3.4 自动模式
+
+配置项:
+
+- `navigationMode = auto`
+- `tabThreshold = 4`
+
+表现:
+
+- L1 指标数超过阈值时自动使用 Tab。
+- 未超过阈值时平铺。
+
+## 4. S1 默认看板设计
+
+S1 产销协同建议按 L1 指标分 Tab,默认覆盖以下分析主题:
+
+- 指标概览
+- 流程效率分析
+- 质量与满意度趋势
+- 人效与资源负荷
+- 库存预警
+- 下钻分析
+- 指标改善闭环
+
+### 4.1 指标概览
+
+展示 S1 核心 L1 与关键 L2:
+
+- 订单评审周期
+- 订单评审满足率
+- 主计划人数
+- 成品库存周转
+- 合同评审周期
+- BOM 评审周期
+- 工艺评审周期
+- 交期评审周期
+
+组件类型:
+
+- `metric_card`
+- `traffic_light_card`
+- `progress_card`
+- `gauge`
+
+### 4.2 流程效率分析
+
+用于分析各关键环节耗时。
+
+组件类型:
+
+- `bar_compare`
+- `funnel_chart`
+- `stage_matrix`
+
+示例:
+
+- 合同评审周期
+- BOM 评审周期
+- 工艺评审周期
+- 交期评审周期
+
+### 4.3 质量与满意度趋势
+
+用于展示满足率、达成率、质量类指标趋势。
+
+组件类型:
+
+- `line_trend`
+- `gauge`
+- `threshold_line`
+
+示例:
+
+- 订单评审满足率
+- 合同评审满足率
+- BOM 评审满足率
+- 工艺评审满足率
+- 交期评审满足率
+
+### 4.4 人效与资源负荷
+
+用于展示主计划、合同、BOM、工艺等环节的人力负荷和人效变化。
+
+组件类型:
+
+- `bar_chart`
+- `heatmap`
+- `resource_load`
+
+示例:
+
+- 主计划人数
+- 合同评审人数
+- BOM 设计人数
+- 工艺设计人数
+
+### 4.5 库存预警
+
+用于展示库存周转和阈值预警。
+
+组件类型:
+
+- `area_chart`
+- `threshold_line`
+- `traffic_light_card`
+
+示例:
+
+- 成品库存周转
+- 库存风险趋势
+- 超红线闪烁预警
+
+## 5. 动态配置模型
+
+建议采用“受控动态配置”,不做完全自由拖拽大屏。
+
+### 5.1 页面配置
+
+`DashboardPageConfig`
+
+- `ModuleCode`
+- `FactoryId`
+- `NavigationMode`
+- `TabSource`
+- `DefaultTabMetricCode`
+- `Theme`
+- `RefreshInterval`
+- `IsEnabled`
+
+### 5.2 Tab 配置
+
+`DashboardTabConfig`
+
+- `TabCode`
+- `TabTitle`
+- `BindMetricCode`
+- `SortNo`
+- `IsDefault`
+- `IsEnabled`
+
+Tab 可以来自 L1 指标,也可以人工配置为“效率、质量、人效、库存”。
+
+### 5.3 区块配置
+
+`DashboardSectionConfig`
+
+- `SectionCode`
+- `SectionTitle`
+- `TabCode`
+- `Layout`
+- `SortNo`
+- `IsCollapsible`
+
+推荐区块编码:
+
+- `overview`
+- `process_efficiency`
+- `quality_trend`
+- `resource_load`
+- `inventory_warning`
+- `drilldown`
+- `improvement_loop`
+
+### 5.4 组件配置
+
+`DashboardWidgetConfig`
+
+- `WidgetCode`
+- `WidgetType`
+- `SectionCode`
+- `TabCode`
+- `Title`
+- `MetricCodes`
+- `DataQueryCode`
+- `Span`
+- `SortNo`
+- `StyleJson`
+- `DrilldownConfigJson`
+
+推荐组件类型:
+
+- `metric_card`
+- `traffic_light_card`
+- `progress_card`
+- `bar_compare`
+- `funnel_chart`
+- `line_trend`
+- `gauge`
+- `heatmap`
+- `threshold_line`
+- `area_chart`
+- `stage_matrix`
+- `drilldown_table`
+- `improvement_loop`
+
+## 6. 红绿灯预警机制
+
+红绿灯状态由后端统一计算,前端只负责表现。
+
+状态建议:
+
+- `green`:达成规划。
+- `yellow`:未达成规划但影响可控。
+- `red`:未达成规划且风险较高。
+- `red_blink`:超过红色阈值,需要闪烁预警。
+
+阈值来源:
+
+- `AdoSmartOpsKpiMaster.Direction`
+- `AdoSmartOpsKpiMaster.YellowThreshold`
+- `AdoSmartOpsKpiMaster.RedThreshold`
+
+闪烁规则:
+
+- 当状态为 `red_blink` 时,卡片边框、状态灯或图表标记点闪烁。
+- 闪烁不应影响文字可读性。
+- 列表或矩阵中红灯项可只闪烁红灯图标,不闪整行。
+
+## 7. 下钻分析设计
+
+下钻需要从指标图表进入业务明细,第一批覆盖:
+
+- 合同评审
+- BOM 评审
+- 工艺评审
+- 交期评审
+
+### 7.1 下钻结构
+
+默认结构按用户指定:
+
+- `关键环节`
+- `目标值`
+- `实际达成`
+- `达标情况`
+
+可配置扩展字段:
+
+- `负责人`
+- `责任部门`
+- `单据号`
+- `客户`
+- `产品`
+- `当前节点`
+- `卡住时长`
+- `计划完成时间`
+- `异常原因`
+
+### 7.2 阶段矩阵
+
+参考示例图,阶段矩阵可以横向展示关键环节。
+
+配置示例:
+
+```json
+{
+  "drilldownType": "stage_matrix",
+  "triggerMetricCode": "S1_L2_BOM_REVIEW_CYCLE",
+  "columns": [
+    { "key": "stageName", "label": "关键环节" },
+    { "key": "targetValue", "label": "目标值" },
+    { "key": "actualValue", "label": "实际达成" },
+    { "key": "statusColor", "label": "达标情况", "renderer": "traffic_light" }
+  ],
+  "stages": ["意见评审", "意见反馈", "二次评审", "领导意见", "合同盖章"]
+}
+```
+
+### 7.3 下钻交互
+
+典型路径:
+
+1. 用户点击 `BOM评审周期` 柱状图。
+2. 弹出下钻面板。
+3. 上方展示关键环节矩阵。
+4. 下方展示卡住的单据明细。
+5. 点击单据可继续查看负责人、卡住时长、异常原因。
+
+所有下钻接口必须继承当前页面筛选条件。
+
+## 8. 指标改善闭环
+
+四宫格诊断组件不再是静态说明,而是一个完整的 `improvement_loop` 组件。
+
+四宫格:
+
+- 目标&现状
+- 主要问题
+- 改善计划
+- 有效性验证
+
+### 8.1 目标&现状
+
+自动生成,不人工填写。
+
+逻辑:
+
+1. 获取当前模块,例如 S1。
+2. 获取当前 Tab 或当前选中 L1。
+3. 判断 L1 是否未达标。
+4. 若当前 L1 达标,则可展示最严重未达标 L1。
+5. 生成目标值、当前值、偏差、状态、统计周期和筛选条件摘要。
+
+示例:
+
+```text
+订单评审周期未达标:目标 8h,当前 14.4h,超出 6.4h,红色预警。
+统计范围:2026-04-01 至 2026-04-26,客户:全部,产品:全部。
+```
+
+### 8.2 主要问题
+
+自动下钻生成,不人工填写。
+
+诊断路径:
+
+```text
+L1 未达标指标
+  → 未达标 L2 指标
+    → 未达标 L3 指标
+      → 关键环节
+        → 单据/负责人/卡住时长
+```
+
+生成内容:
+
+- 最严重问题指标。
+- 未达标路径。
+- 主要责任部门或负责人。
+- 影响单据数。
+- 平均卡住时长。
+- 红灯或黄灯环节摘要。
+
+示例:
+
+```text
+主要问题集中在 BOM评审周期:
+目标 8h,当前 14.2h,超出 6.2h。
+下钻发现 12 张订单卡在 BOM 设计确认环节。
+主要责任部门:技术售前组。
+影响最大的负责人:张三,涉及 5 张单据,平均卡住 9.2h。
+```
+
+### 8.3 改善计划
+
+改善计划由人填写结构化表单,保存后创建改善任务,并通过现有审批流流转。
+
+字段建议:
+
+- 改善指标
+- 当前值
+- 改善目标值
+- 计划开始日期
+- 计划完成日期
+- 责任人
+- 参与人
+- 改善措施列表
+- 优先级
+- 备注
+
+改善措施字段:
+
+- 措施名称
+- 责任人
+- 计划完成时间
+- 预期效果
+- 关联问题
+- 排序
+
+### 8.4 有效性验证
+
+验证由用户点击触发,也可以在审批流验证节点触发。
+
+验证内容:
+
+- 改善任务创建时的基线值。
+- 当前最新值。
+- 改善目标值。
+- 从任务创建日至今的趋势图。
+- 改善动作节点在趋势图上的标记。
+- 验证结论。
+
+结论建议:
+
+- `effective`:达到改善目标。
+- `partially_effective`:有改善但未达到目标。
+- `ineffective`:无改善。
+- `worse`:指标恶化。
+
+示例:
+
+```text
+基线:BOM评审周期 14.2h
+当前:10.8h
+改善目标:10h
+变化:下降 3.4h,改善 23.9%
+结论:部分有效,尚未达到目标。
+```
+
+## 9. 基于审批流扩展改善任务
+
+改善任务不另起孤立任务系统,而是作为现有审批流插件的一类业务单据接入。
+
+推荐业务类型:
+
+```text
+SMART_OPS_IMPROVEMENT
+```
+
+### 9.1 流程节点建议
+
+默认流程:
+
+1. 发起改善任务。
+2. 责任人确认。
+3. 改善措施执行。
+4. 改善结果提交。
+5. 有效性验证。
+6. 关闭。
+
+异常路径:
+
+- 驳回改善计划。
+- 退回修改目标。
+- 重新改善。
+- 取消任务。
+- 超时升级。
+
+### 9.2 与审批流插件的集成方式
+
+按照现有审批流集成规范:
+
+- 后端新增改善任务业务表。
+- 新增 `IFlowBizHandler` 实现,`BizType = SMART_OPS_IMPROVEMENT`。
+- 改善计划保存后调用审批流通用发起能力创建流程实例。
+- 审批流负责待办、处理人、流程图、审批记录、通知、代理、超时升级。
+- 业务表保存指标诊断快照、基线值、目标值、验证结果。
+
+### 9.3 业务表建议
+
+改善任务表:`ado_smart_ops_improvement_task`
+
+- `Id`
+- `TenantId`
+- `FactoryId`
+- `ModuleCode`
+- `MetricCode`
+- `MetricLevel`
+- `TaskTitle`
+- `BaselineValue`
+- `TargetValue`
+- `CurrentValue`
+- `Unit`
+- `BaselineDate`
+- `PlanStartDate`
+- `PlanEndDate`
+- `OwnerUserId`
+- `OwnerName`
+- `FlowInstanceId`
+- `FlowStatus`
+- `TaskStatus`
+- `DiagnosisSnapshotJson`
+- `FilterContextJson`
+- `CreatedAt`
+- `UpdatedAt`
+
+改善动作表:`ado_smart_ops_improvement_action`
+
+- `Id`
+- `TaskId`
+- `ActionName`
+- `OwnerUserId`
+- `OwnerName`
+- `PlanDueDate`
+- `ActualFinishDate`
+- `ExpectedEffect`
+- `Status`
+- `SortNo`
+
+验证记录表:`ado_smart_ops_improvement_verification`
+
+- `Id`
+- `TaskId`
+- `VerifyDate`
+- `BaselineValue`
+- `CurrentValue`
+- `TargetValue`
+- `ImprovementRate`
+- `Conclusion`
+- `TrendSnapshotJson`
+- `ActionMilestonesJson`
+- `CreatedBy`
+
+### 9.4 状态映射
+
+业务状态:
+
+- `draft`
+- `in_approval`
+- `active`
+- `executing`
+- `pending_verify`
+- `verified`
+- `closed`
+- `rejected`
+- `cancelled`
+
+审批流回调建议:
+
+- `OnFlowStarted`:业务状态更新为 `in_approval`。
+- `OnNodeCompleted`:按节点推进执行、验证等状态。
+- `OnFlowCompleted Approved`:进入 `active` 或 `closed`,取决于流程设计。
+- `OnFlowCompleted Rejected`:进入 `rejected`。
+- `OnFlowCompleted Cancelled`:进入 `cancelled`。
+
+## 10. 推荐接口
+
+### 10.1 动态看板
+
+`GET /api/AidopKanban/dashboard-page/{moduleCode}`
+
+返回:
+
+- 页面模式
+- Tab
+- 区块
+- 组件
+- 指标定义
+- 当前值
+- 目标值
+- 状态
+- 趋势
+- 公式说明
+
+### 10.2 下钻分析
+
+`GET /api/AidopKanban/drilldown/{moduleCode}/{metricCode}`
+
+参数:
+
+- 当前筛选条件
+- `drilldownType`
+- `stageCode`
+
+返回:
+
+- 关键环节矩阵
+- 卡住单据列表
+- 负责人聚合
+- 异常原因聚合
+
+### 10.3 自动诊断
+
+`GET /api/AidopImprovement/diagnose`
+
+参数:
+
+- `moduleCode`
+- `metricCode` 可选
+- 当前筛选条件
+
+返回:
+
+- `goalStatus`
+- `problemSummary`
+- `diagnosisPath`
+- `leafProblems`
+- `suggestedOwner`
+- `suggestedTargetValue`
+
+### 10.4 创建改善任务
+
+`POST /api/AidopImprovement/tasks`
+
+输入:
+
+- 诊断结果快照
+- 改善目标值
+- 计划时间
+- 责任人
+- 改善动作
+
+行为:
+
+- 保存改善任务业务数据。
+- 发起 `SMART_OPS_IMPROVEMENT` 审批流实例。
+- 生成责任人待办。
+
+### 10.5 有效性验证
+
+`POST /api/AidopImprovement/tasks/{id}/verify`
+
+返回:
+
+- 基线值
+- 当前值
+- 改善目标值
+- 趋势数据
+- 改善动作节点
+- 验证结论
+
+## 11. 前端交互设计
+
+### 11.1 动态看板页面
+
+页面顶部:
+
+- 模块名称
+- 当前筛选条件
+- 更新时间
+- 红黄绿统计
+- 配置入口
+
+页面主体:
+
+- 根据配置显示 Tab 或平铺。
+- 每个 Tab 内按区块显示组件。
+- 每个组件可绑定下钻。
+
+### 11.2 下钻弹窗
+
+弹窗结构:
+
+- 标题:指标名称 + 当前状态。
+- 上部:关键环节矩阵。
+- 中部:卡住单据列表。
+- 下部:负责人/部门聚合。
+- 操作:创建改善任务、导出、跳转单据。
+
+### 11.3 智慧诊断专页
+
+详情看板提供两类 `智慧诊断` 入口。
+
+- 模块级入口:详情看板顶部 `智慧诊断` 按钮只携带当前模块和筛选条件,例如 `/aidop/smart-diagnosis?module=S1`;后端自动选择当前模块下最严重的 L1 作为诊断对象。
+- 指标级入口:每张 L1 指标卡片右上角提供小号 `诊断` 按钮,携带当前模块、当前 L1 指标和筛选条件,例如 `/aidop/smart-diagnosis?module=S1&metricCode=S1_L1_001`。
+
+模块级入口默认 L1 选择规则:红色优先,多个红色时按偏离目标程度排序;没有红色则选黄色;全部达标时选排序最靠前的 L1。
+
+诊断页参考现有 `智慧诊断` 的矩阵样式:
+
+- 顶部展示当前模块和当前 L1 指标的诊断上下文。
+- 主体展示 L1 下层 L2 关键环节矩阵,包括负责部门、KPI、实际达成、达标情况。
+- 点击红/黄节点后,按 L2 → L3 展开下一级矩阵。
+- 左下保留四宫格闭环入口:`目标&现状`、`主要问题`、`改善计划`、`有效性验证`。
+- 右侧或顶部保留红黄绿图例。
+
+第一版智慧诊断先基于 KPI 层级和值表自动生成,不接完整单据明细下钻。
+
+### 11.4 四宫格改善闭环组件
+
+四宫格:
+
+- `目标&现状`:自动生成,只读。
+- `主要问题`:自动诊断,只读,可点击明细。
+- `改善计划`:结构化表单,可保存并发起审批流。
+- `有效性验证`:任务创建后启用,点击后展示趋势和结论。
+
+### 11.5 详情看板配置菜单
+
+详情看板配置建议作为智慧运营下的独立配置菜单,不放在 S1 详情页内,也不混入运营指标主数据。
+
+建议菜单结构:
+
+```text
+Ai-DOP
+└─ 智慧运营
+   ├─ 九宫格智慧运营看板
+   ├─ 运营指标主数据
+   ├─ 业务事实字典
+   ├─ 运营指标建模
+   └─ 详情看板配置
+```
+
+建议菜单名称:
+
+```text
+详情看板配置
+```
+
+建议路由:
+
+```text
+/aidop/smart-ops/dashboard-config
+```
+
+页面职责:
+
+- 选择模块:S1/S2/S3/S4/S5/S6/S7/S9。
+- 配置页面模式:普通 Tab、L1 指标卡片、平铺、自动。
+- 配置 Tab:按 L1 自动生成或自定义 Tab。
+- 配置区块:指标概览、流程效率、趋势、人效、库存、下钻、改善闭环。
+- 配置组件:图表类型、绑定指标、样式、排序、显示/隐藏。
+- 配置下钻:下钻类型、关键环节、展示字段、明细字段。
+- 配置诊断闭环:是否启用四宫格、绑定指标、改善任务流程类型。
+- 预览当前模块详情看板。
+- 保存后刷新对应详情页。
+
+职责边界:
+
+- `运营指标主数据`:指标是什么、怎么算、阈值是什么。
+- `运营指标建模`:指标关系、公式、首页指标选择。
+- `详情看板配置`:详情页布局、组件、图表样式、下钻和改善组件配置。
+
+## 12. 分阶段实施路线
+
+### 阶段一:S1 动态看板增强
+
+#### 阶段一 A:动态布局与组件注册表
+
+目标是先搭好动态看板框架。
+
+- 定义页面模式:`tabs_by_l1`、`l1_cards`、`single_page`、`auto`。
+- 定义 Tab、Section、Widget 配置结构。
+- 建立前端组件注册表,采用 `WidgetType -> Vue 组件` 的受控扩展模式。
+- 后端返回统一看板配置 DTO。
+- S1 默认按 L1 指标生成 Tab。
+- 支持无配置时按运营指标主数据自动生成默认布局。
+
+#### 阶段一 B:Chart 组件与样式配置
+
+目标是专门落地图表能力和样式配置。
+
+- 实现基础图表组件类型:
+  - `bar_compare`
+  - `funnel_chart`
+  - `line_trend`
+  - `gauge`
+  - `heatmap`
+  - `threshold_line`
+  - `area_chart`
+- 每类图表支持 `StyleJson`:
+  - 颜色主题
+  - 阈值线
+  - 红灯闪烁
+  - 图表方向
+  - 是否显示目标线
+  - 是否显示图例
+  - 是否显示单位
+  - 最大值/最小值
+  - 数据标签位置
+  - tooltip 展示字段
+- 建立默认样式模板,避免每个图表都必须手工配置。
+- 图表样式注册表和后端 `WidgetType`、`StyleJson` 保持白名单约束,避免任意脚本或任意配置注入。
+
+#### 阶段一 C:S1 默认模板落地
+
+目标是让 S1 详情看板先形成可用样板。
+
+- S1 默认使用 `l1_cards`:所有 L1 以核心指标卡片显示,点击卡片切换下方 L2 指标卡片和图表内容。
+- 默认区块:
+  - 指标概览
+  - 流程效率分析
+  - 质量与满意度趋势
+  - 人效与资源负荷
+  - 库存预警
+- 默认绑定 S1 的 L1/L2 指标。
+- 默认启用红绿灯状态。
+- 默认下钻入口先覆盖合同评审、BOM 评审、工艺评审、交期评审。
+
+#### 阶段一 D:配置页基础能力
+
+目标是让用户可以维护配置,而不是只能依赖默认模板。
+
+- 新增智慧运营菜单:`详情看板配置`。
+- 支持选择模块。
+- 支持选择普通 Tab、L1 指标卡片、平铺、自动模式。
+- 支持配置组件类型。
+- 支持配置组件绑定指标。
+- 支持配置图表样式。
+- 支持配置组件排序和显示/隐藏。
+- 支持保存后刷新对应模块详情看板。
+- 支持预览当前配置。
+
+### 阶段二:智慧诊断专页
+
+- 在详情看板顶部增加模块级 `智慧诊断` 入口。
+- 在 L1 指标卡片右上角增加指标级 `诊断` 入口。
+- 顶部入口只携带当前模块和筛选条件,由后端自动选择最严重 L1。
+- 指标卡入口携带当前模块、当前 L1 指标和筛选条件。
+- 复用现有 `智慧诊断` 矩阵视觉样式。
+- 实现 L1 → L2 → L3 自动诊断矩阵。
+- 实现主要问题自动定位和红黄绿状态展示。
+- 四宫格闭环第一版先作为诊断页入口/结构保留,改善计划保存与审批流留到阶段三。
+
+### 阶段三:审批流集成
+
+- 注册 `SMART_OPS_IMPROVEMENT` 业务类型。
+- 新增改善任务业务表和动作表。
+- 实现 `IFlowBizHandler`。
+- 改善计划保存后发起审批流程。
+- 流程节点状态回写四宫格。
+
+### 阶段四:有效性验证
+
+- 实现任务基线记录。
+- 实现趋势对比。
+- 在趋势图标记改善动作节点。
+- 输出验证结论。
+
+### 阶段五:推广到其它模块
+
+- 将 S2/S3/S4/S5/S6/S7/S9 接入同一套动态看板。
+- 按模块配置默认 Tab、区块和组件。
+- S8 保留异常监控独立模型,必要时只复用改善闭环能力。
+
+## 13. 测试验证方案
+
+### 13.1 配置测试
+
+- Tab 模式、平铺模式、自动模式是否正确切换。
+- S1 是否按 L1 自动生成 Tab。
+- 组件绑定不同指标后是否正确展示。
+- 组件排序、隐藏、标题、样式是否生效。
+- 下钻字段配置是否正确映射到弹窗。
+- 红灯闪烁配置是否只对红色超限项生效。
+
+### 13.2 数据测试
+
+- L1/L2/L3 当前值、目标值、单位、阈值是否正确读取。
+- `higher_is_better` 和 `lower_is_better` 两类方向是否正确判定红黄绿。
+- 趋势数据是否按筛选条件和统计周期返回。
+- 无日值数据时是否有明确空态,不报错。
+- 指标禁用后是否不再进入看板。
+
+### 13.3 筛选测试
+
+- 日期范围筛选是否影响指标、图表、下钻和诊断。
+- 产品、订单号、产线、客户筛选是否同步作用于所有组件。
+- 下钻弹窗是否继承页面筛选条件。
+- 重置筛选后页面是否恢复默认数据。
+
+### 13.4 下钻测试
+
+- 点击 `合同评审周期` 是否进入合同评审下钻。
+- 点击 `BOM评审周期` 是否进入 BOM 评审下钻。
+- 点击 `工艺评审周期` 是否进入工艺评审下钻。
+- 点击 `交期评审周期` 是否进入交期评审下钻。
+- 关键环节、目标值、实际达成、达标情况是否正确。
+- 卡住单据、负责人、卡住时长是否与筛选条件一致。
+- 红灯环节是否闪烁或高亮。
+
+### 13.5 自动诊断测试
+
+- 当前模块存在多个未达标 L1 时,是否选出最严重项。
+- 指定某个 L1 时,是否只诊断该指标链路。
+- L1 未达标时,是否继续定位未达标 L2。
+- L2 未达标时,是否继续定位未达标 L3。
+- 最后一层无明细时,是否给出“无明细支撑”的说明。
+- 目标&现状是否自动写入当前值、目标值、偏差、状态。
+- 主要问题是否自动写入未达标路径、责任部门、负责人和单据摘要。
+
+### 13.6 改善任务与审批流测试
+
+- 改善计划保存后是否创建改善任务业务记录。
+- 是否成功发起 `SMART_OPS_IMPROVEMENT` 审批流实例。
+- 责任人是否收到待办。
+- 审批中心是否能看到该待办。
+- 审批通过、拒绝、撤回、退回是否正确回写业务状态。
+- 节点超时、升级、代理是否按审批流既有能力生效。
+- 流程时间线是否能追踪每个动作。
+- 不同租户之间改善任务、待办、流程实例是否隔离。
+
+### 13.7 有效性验证测试
+
+- 验证时是否读取任务创建时的基线值。
+- 是否读取当前最新值。
+- 是否展示从任务创建日至今的趋势。
+- 改善动作节点是否标记在趋势图上。
+- 达到目标、部分改善、无改善、恶化四类结论是否准确。
+- 验证记录是否保存,并能追溯历史验证结果。
+
+### 13.8 回归测试
+
+- 九宫格首页仍能正常加载。
+- 运营指标主数据新增、编辑、禁用、排序不受影响。
+- Demo 数据导入不受影响。
+- 现有 S2/S3/S4/S5/S6/S7/S9 页面不因 S1 试点异常而不可用。
+- 审批流原有订单评审、合同评审流程不受影响。
+
+### 13.9 异常测试
+
+- 无指标主数据。
+- 有指标但无日值。
+- 有红灯但无下钻明细。
+- 公式缺失或公式引用不存在。
+- 责任人为空。
+- 审批流未发布。
+- 审批流发起失败。
+- 下钻接口失败。
+- 趋势接口超时。
+
+每类异常都应有明确提示,并避免整页崩溃。
+
+## 14. 关键决策建议
+
+1. S1 默认采用 `l1_cards`,即用所有 L1 核心指标卡片作为 Tab 标签;其它模块可按指标数量选择普通 Tab、L1 卡片或平铺。
+2. 第一版下钻矩阵固定支持“关键环节、目标值、实际达成、达标情况”,其它字段可配置追加。
+3. 红色超限闪烁作为组件统一能力,不在每个页面单独实现。
+4. 四宫格诊断升级为 `improvement_loop` 组件。
+5. 改善任务必须基于现有审批流扩展,避免另建孤立任务系统。
+6. 自动诊断第一版先基于 KPI 层级和已有明细数据,不引入 AI 自动生成。
+7. 有效性验证第一版以趋势对比和动作节点标记为主,后续可扩展统计显著性或 AI 总结。
+
+## 15. 当前实施进度与后续待办(2026-04-26)
+
+### 15.1 已完成范围
+
+当前已完成阶段一到阶段三的主流程闭环:
+
+- **阶段一:动态运营详情看板**
+  - 已形成 S1 产销协同详情看板的动态化基础,后端支持页面配置、组件配置、指标绑定与默认配置生成。
+  - 前端已将 S1 详情看板切换为通用动态看板组件,支持 L1 核心指标卡片作为 Tab 标签,点击 L1 后展示对应 L2 指标卡片与图表。
+  - 看板配置页已支持组件类型预览,便于选择指标卡、趋势图、柱状图、仪表盘、明细表等组件类型。
+  - 图表红绿灯预警已细化为对未达标柱子/指标单元闪烁或高亮,指标卡“差异”文案已调整为“期量差”。
+
+- **阶段二:智慧诊断**
+  - 已新增 S1 顶部“智慧诊断”入口,并支持 L1 指标卡片上的指标级诊断入口。
+  - 模块级入口只传 `moduleCode`,后端自动选择最严重 L1;指标级入口传 `moduleCode + metricCode`,用于指定 L1 诊断。
+  - 智慧诊断页已采用横向四卡片流程:目标&现状、主要问题、改善计划、有效性验证。
+  - “主要问题”已支持 L2/L3/L4 级展开,问题链路中的 L2/L3/L4 问题节点支持红色闪烁,并将连接箭头对齐到对应问题环节下方。
+  - KPI 主数据与日值链路已扩展到 L4,新增 `ado_s9_kpi_value_l4_day` 初始化与读数逻辑,S1 BOM 评审周期相关 L3/L4 Demo 数据已落入种子与导入模板链路。
+
+- **阶段三:诊断改善闭环落地**
+  - 已新增 `AdoSmartOpsImprovementPlan` 改善计划业务模型和接口。
+  - 智慧诊断页已支持根据当前主要问题生成改善计划,包含根因分析、行动项、责任人、计划完成日期等信息。
+  - 已接入现有审批流引擎,业务类型为 `SMART_OPS_IMPROVEMENT`。
+  - 已新增默认已发布审批流初始化,避免首次提交审批时报“未找到已发布流程定义”。
+  - 已新增 `SmartOpsImprovementBizHandler`,审批开始、审批完成、驳回等动作可回写改善计划状态。
+  - 有效性验证已从简单表单升级为验证看板:
+    - 以改善计划生成日至计划完成日为自然日刻度生成时间线。
+    - 行动项按计划完成日期挂到时间线节点。
+    - 时间线显示行动项状态与实际完成日期。
+    - 折线图展示改善目标历史用时,并叠加目标用时线。
+  - 有效性验证时间线已支持直接更新行动项状态:未开始、进行中、已完成;标记完成时自动写入实际完成日期。
+
+### 15.2 稳定性与基础设施已完成
+
+- 数据库 CodeFirst 初始化已改为默认顺序执行,并支持配置最大并发度、重试次数、重试间隔和 DDL 命令超时,降低 MySQL 初始化超时导致的 502 风险。
+- L3/L4 指标种子、L4 日值表创建、Demo 导入逻辑均采用幂等方式,避免重复启动或重复导入导致数据冲突。
+- 动态看板、智慧诊断、改善计划审批、有效性验证相关前后端代码已通过构建或局部 lint 验证。
+
+### 15.3 尚未完成的待办
+
+- **行动项任务化**
+  - 当前行动项仍存储在改善计划 JSON 中,已可更新状态,但尚未拆分为独立行动项表。
+  - 后续建议增加行动项独立 ID、负责人、完成日志、变更人、变更时间,便于审计和协同。
+
+- **待办与提醒**
+  - 当前改善计划审批可进入审批流待办,但行动项本身还未生成个人待办。
+  - 后续可接入系统待办、站内通知或消息提醒,实现责任人行动项催办。
+
+- **业务单据级追溯**
+  - 当前有效性验证以 KPI 日值趋势为主。
+  - 后续需要继续打通真实业务单据,如具体评审单、负责人、卡滞时长、处理记录等,以支撑“为什么改善有效/无效”的证据链。
+
+- **改善计划台账**
+  - 当前改善计划主要嵌在智慧诊断页中使用。
+  - 后续建议新增“改善计划台账”菜单页,支持按模块、状态、责任人、日期、问题指标筛选和导出。
+
+- **正式审批流配置**
+  - 当前默认审批流为兜底单节点流程,保证开箱可用。
+  - 正式业务上线前建议在审批流管理中配置多级审批,如部门负责人、项目经理、运营负责人等,并按问题等级设置网关条件。
+
+- **多模块推广**
+  - S1 已作为试点完成主链路。
+  - 后续需将动态详情看板、智慧诊断和改善闭环推广到 S2/S3/S4/S5/S6/S7/S9;S8 继续保留异常监控独立模型,只复用改善闭环能力。
+
+- **验证口径增强**
+  - 当前有效性验证展示趋势对比和目标线。
+  - 后续可增加改善前后均值、改善幅度、连续达标天数、样本量说明和 AI 总结,减少人工判断成本。
+
+### 15.4 下一步建议优先级
+
+1. 优先做“改善计划台账”,让已生成的改善计划可统一查询、跟踪、筛选和导出。
+2. 将行动项从 JSON 升级为独立表,补齐行动项负责人待办、完成日志和逾期提醒。
+3. 补业务单据级追溯接口,将 KPI 趋势和真实卡滞单据关联起来。
+4. 配置正式 `SMART_OPS_IMPROVEMENT` 审批流,替换当前默认兜底流程。
+5. 以 S2/S4/S6 为下一批模块验证动态看板配置和诊断闭环复用能力。
+

+ 5 - 1
server/Admin.NET.Application/Configuration/Database.json

@@ -39,7 +39,11 @@
         },
         "TableSettings": {
           "EnableInitTable": true, // 启用表结构 CodeFirst 初始化/增量对齐
-          "EnableIncreTable": false // 启用表增量更新(只更新贴了特性[IncreTable]的实体表)
+          "EnableIncreTable": false, // 启用表增量更新(只更新贴了特性[IncreTable]的实体表)
+          "InitTableMaxDegreeOfParallelism": 1, // 远端 MySQL DDL 初始化串行执行,避免元数据锁互相阻塞
+          "InitTableRetryCount": 3, // 表结构初始化失败重试次数
+          "InitTableRetryIntervalMs": 2000, // 表结构初始化失败重试间隔(毫秒)
+          "InitTableCommandTimeoutSeconds": 120 // 表结构初始化命令超时时间(秒)
         },
         "SeedSettings": {
           "EnableInitSeed": false, // 启用种子初始化(需要初始化字典/菜单数据时改 true)

+ 20 - 0
server/Admin.NET.Core/Option/DbConnectionOptions.cs

@@ -117,6 +117,26 @@ public sealed class TableSettings
     /// 启用表增量更新
     /// </summary>
     public bool EnableIncreTable { get; set; }
+
+    /// <summary>
+    /// 表初始化最大并发数,远端 MySQL 建议为 1,避免 DDL 元数据锁互相阻塞
+    /// </summary>
+    public int InitTableMaxDegreeOfParallelism { get; set; } = 1;
+
+    /// <summary>
+    /// 表初始化失败重试次数
+    /// </summary>
+    public int InitTableRetryCount { get; set; } = 3;
+
+    /// <summary>
+    /// 表初始化失败重试间隔(毫秒)
+    /// </summary>
+    public int InitTableRetryIntervalMs { get; set; } = 2000;
+
+    /// <summary>
+    /// 表初始化命令超时时间(秒)
+    /// </summary>
+    public int InitTableCommandTimeoutSeconds { get; set; } = 120;
 }
 
 /// <summary>

+ 90 - 10
server/Admin.NET.Core/SqlSugar/SqlSugarSetup.cs

@@ -445,8 +445,17 @@ public static class SqlSugarSetup
         {
             Log.Information($"初始化表结构 {config.DbType} - {config.ConfigId}");
             var entityTypes = GetEntityTypesForInit(config);
-            RunPreInitMigrations(dbProvider, entityTypes);
-            InitializeTables(dbProvider, entityTypes, config);
+            var originCommandTimeout = dbProvider.Ado.CommandTimeOut;
+            dbProvider.Ado.CommandTimeOut = ResolveInitTableCommandTimeout(config);
+            try
+            {
+                RunPreInitMigrations(dbProvider, entityTypes);
+                InitializeTables(dbProvider, entityTypes, config);
+            }
+            finally
+            {
+                dbProvider.Ado.CommandTimeOut = originCommandTimeout;
+            }
         }
 
         // 初始化视图
@@ -541,15 +550,24 @@ public static class SqlSugarSetup
             }
         }
 
+        var maxDegree = ResolveInitTableMaxDegreeOfParallelism(config);
         int count = 0, sum = entityTypes.Count;
-        var tasks = entityTypes.Select(entityType => Task.Run(() =>
+
+        void InitOne(Type entityType)
         {
             Console.WriteLine($"初始化表结构 {entityType.FullName,-64} ({config.ConfigId} - {Interlocked.Increment(ref count):D003}/{sum:D003})");
             UpdateNullableColumns(dbProvider, entityType);
-            InitializeTable(dbProvider, entityType);
-        }));
+            InitializeTable(dbProvider, entityType, config);
+        }
 
-        Task.WhenAll(tasks).GetAwaiter().GetResult();
+        if (maxDegree <= 1)
+        {
+            foreach (var entityType in entityTypes)
+                InitOne(entityType);
+            return;
+        }
+
+        Parallel.ForEach(entityTypes, new ParallelOptions { MaxDegreeOfParallelism = maxDegree }, InitOne);
     }
 
     /// <summary>
@@ -577,7 +595,7 @@ public static class SqlSugarSetup
     /// </summary>
     /// <param name="dbProvider">SqlSugarScopeProvider 实例</param>
     /// <param name="entityType">实体类型</param>
-    private static void InitializeTable(SqlSugarScopeProvider dbProvider, Type entityType)
+    private static void InitializeTable(SqlSugarScopeProvider dbProvider, Type entityType, DbConnectionConfig config)
     {
         Retry(() =>
         {
@@ -589,7 +607,7 @@ public static class SqlSugarSetup
             {
                 dbProvider.CodeFirst.SplitTables().InitTables(entityType);
             }
-        }, maxRetry: 3, retryIntervalMs: 1000);
+        }, ResolveInitTableRetryCount(config), ResolveInitTableRetryIntervalMs(config));
     }
 
     /// <summary>
@@ -774,15 +792,77 @@ public static class SqlSugarSetup
                 action();
                 return;
             }
-            catch (SqliteException ex) when (ex.SqliteErrorCode == 5) // SQLITE_BUSY
+            catch (Exception ex) when (IsRetryableDatabaseInitException(ex))
             {
                 if (++attempt >= maxRetry)
                 {
                     Log.Error($"简单的重试机制:{ex.Message}"); throw;
                 }
-                Log.Information($"数据库忙,正在重试... (尝试 {attempt}/{maxRetry})");
+                Log.Information($"数据库初始化异常,正在重试... (尝试 {attempt}/{maxRetry}) {ex.Message}");
                 Thread.Sleep(retryIntervalMs);
             }
         }
     }
+
+    private static int ResolveInitTableMaxDegreeOfParallelism(DbConnectionConfig config)
+    {
+        var value = config.TableSettings?.InitTableMaxDegreeOfParallelism ?? 1;
+        return value <= 0 ? 1 : value;
+    }
+
+    private static int ResolveInitTableRetryCount(DbConnectionConfig config)
+    {
+        var value = config.TableSettings?.InitTableRetryCount ?? 3;
+        return value <= 0 ? 1 : value;
+    }
+
+    private static int ResolveInitTableRetryIntervalMs(DbConnectionConfig config)
+    {
+        var value = config.TableSettings?.InitTableRetryIntervalMs ?? 2000;
+        return value < 0 ? 2000 : value;
+    }
+
+    private static int ResolveInitTableCommandTimeout(DbConnectionConfig config)
+    {
+        var value = config.TableSettings?.InitTableCommandTimeoutSeconds ?? 120;
+        return value <= 0 ? 120 : value;
+    }
+
+    private static bool IsRetryableDatabaseInitException(Exception ex)
+    {
+        if (ex is SqliteException sqliteException && sqliteException.SqliteErrorCode == 5)
+            return true;
+
+        var current = ex;
+        while (current != null)
+        {
+            if (current is TimeoutException)
+                return true;
+
+            var typeName = current.GetType().FullName ?? "";
+            var message = current.Message ?? "";
+            if (typeName.Contains("SqlSugarException", StringComparison.OrdinalIgnoreCase) &&
+                ContainsRetryableDatabaseMessage(message))
+                return true;
+            if (typeName.Contains("MySql", StringComparison.OrdinalIgnoreCase) &&
+                ContainsRetryableDatabaseMessage(message))
+                return true;
+            if (ContainsRetryableDatabaseMessage(message))
+                return true;
+
+            current = current.InnerException;
+        }
+
+        return false;
+    }
+
+    private static bool ContainsRetryableDatabaseMessage(string message)
+    {
+        return message.Contains("timeout", StringComparison.OrdinalIgnoreCase)
+               || message.Contains("timed out", StringComparison.OrdinalIgnoreCase)
+               || message.Contains("Lock wait timeout", StringComparison.OrdinalIgnoreCase)
+               || message.Contains("Deadlock found", StringComparison.OrdinalIgnoreCase)
+               || message.Contains("database is locked", StringComparison.OrdinalIgnoreCase)
+               || message.Contains("SQLITE_BUSY", StringComparison.OrdinalIgnoreCase);
+    }
 }

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.70</AssemblyVersion>
-    <FileVersion>1.0.70</FileVersion>
-    <Version>1.0.70</Version>
+    <AssemblyVersion>1.0.71</AssemblyVersion>
+    <FileVersion>1.0.71</FileVersion>
+    <Version>1.0.71</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 534 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AdoSmartOpsImprovementPlanController.cs

@@ -0,0 +1,534 @@
+using System.Globalization;
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.Entity;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Service;
+using Admin.NET.Plugin.ApprovalFlow.Service;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+[AllowAnonymous]
+[NonUnify]
+public class AdoSmartOpsImprovementPlanController : ControllerBase
+{
+    private readonly ISqlSugarClient _db;
+    private readonly FlowEngineService _flowEngine;
+
+    public AdoSmartOpsImprovementPlanController(ISqlSugarClient db, FlowEngineService flowEngine)
+    {
+        _db = db;
+        _flowEngine = flowEngine;
+    }
+
+    [HttpPost("createFromDiagnosis")]
+    public async Task<IActionResult> CreateFromDiagnosis([FromBody] ImprovementPlanCreateInput input)
+    {
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        var now = DateTime.Now;
+        var entity = new AdoSmartOpsImprovementPlan
+        {
+            TenantId = tenantId,
+            FactoryId = input.FactoryId <= 0 ? 1 : input.FactoryId,
+            PlanNo = await GeneratePlanNoAsync(),
+            ModuleCode = (input.ModuleCode ?? "").Trim().ToUpperInvariant(),
+            MetricCode = input.MetricCode,
+            ProblemLevel = input.ProblemLevel,
+            ProblemMetricCode = input.ProblemMetricCode,
+            ProblemName = input.ProblemName ?? "",
+            ProblemDept = input.ProblemDept,
+            TargetValue = input.TargetValue,
+            ActualValue = input.ActualValue,
+            GapLabel = input.GapLabel,
+            RootCause = input.RootCause,
+            ActionItemsJson = JsonSerializer.Serialize(input.ActionItems ?? new List<ImprovementActionItem>()),
+            OwnerUserId = input.OwnerUserId,
+            DueDate = input.DueDate,
+            Status = "draft",
+            CreateTime = now,
+            UpdateTime = now,
+        };
+
+        if (string.IsNullOrWhiteSpace(entity.ModuleCode)) return BadRequest(new { message = "模块编码不能为空" });
+        if (string.IsNullOrWhiteSpace(entity.ProblemName)) return BadRequest(new { message = "问题名称不能为空" });
+
+        entity.Id = await _db.Insertable(entity).ExecuteReturnBigIdentityAsync();
+        return Ok(ToOutput(entity));
+    }
+
+    [HttpPost("page")]
+    public async Task<IActionResult> Page([FromBody] ImprovementPlanPageInput input)
+    {
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 20 : input.PageSize;
+        var q = _db.Queryable<AdoSmartOpsImprovementPlan>()
+            .Where(x => x.TenantId == tenantId)
+            .WhereIF(!string.IsNullOrWhiteSpace(input.ModuleCode), x => x.ModuleCode == input.ModuleCode)
+            .WhereIF(!string.IsNullOrWhiteSpace(input.Status), x => x.Status == input.Status)
+            .WhereIF(!string.IsNullOrWhiteSpace(input.Keyword), x => x.PlanNo.Contains(input.Keyword!) || x.ProblemName.Contains(input.Keyword!))
+            .OrderByDescending(x => x.CreateTime);
+        var result = await q.ToPagedListAsync(page, pageSize);
+        return Ok(new
+        {
+            result.Page,
+            result.PageSize,
+            result.Total,
+            Items = result.Items.Select(ToOutput).ToList()
+        });
+    }
+
+    [HttpGet("detail")]
+    public async Task<IActionResult> Detail([FromQuery] long id)
+    {
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        var entity = await _db.Queryable<AdoSmartOpsImprovementPlan>()
+            .FirstAsync(x => x.Id == id && x.TenantId == tenantId);
+        return entity == null ? NotFound() : Ok(ToOutput(entity));
+    }
+
+    [HttpGet("verificationDashboard")]
+    public async Task<IActionResult> VerificationDashboard([FromQuery] long id)
+    {
+        var plan = await LoadPlanAsync(id) ?? throw Oops.Oh("改善计划不存在");
+        var actions = ParseActionItems(plan.ActionItemsJson);
+        var startDate = plan.CreateTime.Date;
+        var endDate = ResolveVerificationEndDate(plan, actions);
+        if (endDate < startDate) endDate = startDate;
+
+        var metricCode = (plan.ProblemMetricCode ?? plan.MetricCode ?? "").Trim();
+        var metricMeta = string.IsNullOrWhiteSpace(metricCode)
+            ? null
+            : await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.TenantId == plan.TenantId && x.MetricCode == metricCode);
+        var metricLevel = metricMeta?.MetricLevel ?? NormalizeMetricLevel(plan.ProblemLevel);
+        var targetFallback = ParseDecimal(plan.TargetValue);
+        var table = ResolveKpiValueTable(metricLevel);
+        var trendRows = await QueryVerificationTrendAsync(table, plan, metricCode, startDate, endDate);
+        if (trendRows.Count == 0)
+        {
+            foreach (var fallbackTable in new[] { "ado_s9_kpi_value_l4_day", "ado_s9_kpi_value_l3_day", "ado_s9_kpi_value_l2_day", "ado_s9_kpi_value_l1_day" })
+            {
+                if (fallbackTable == table) continue;
+                trendRows = await QueryVerificationTrendAsync(fallbackTable, plan, metricCode, startDate, endDate);
+                if (trendRows.Count > 0) break;
+            }
+        }
+
+        var trendByDate = trendRows
+            .GroupBy(x => x.BizDate.Date)
+            .ToDictionary(x => x.Key, x => x.OrderByDescending(r => r.TargetValue.HasValue).First());
+        var timeline = BuildDateRange(startDate, endDate)
+            .Select(day => new
+            {
+                Date = day.ToString("yyyy-MM-dd"),
+                IsStart = day == startDate,
+                IsEnd = day == endDate,
+                Actions = actions
+                    .Select((action, index) => BuildVerificationActionNode(action, index, day))
+                    .Where(x => x != null)
+                    .ToList(),
+            })
+            .ToList();
+        var series = BuildDateRange(startDate, endDate)
+            .Select(day =>
+            {
+                trendByDate.TryGetValue(day, out var row);
+                return new
+                {
+                    Date = day.ToString("yyyy-MM-dd"),
+                    Value = row?.MetricValue,
+                    Target = row?.TargetValue ?? targetFallback,
+                };
+            })
+            .ToList();
+
+        return Ok(new
+        {
+            Plan = ToOutput(plan),
+            Metric = new
+            {
+                MetricCode = metricCode,
+                MetricName = metricMeta?.MetricName ?? plan.ProblemName,
+                Unit = metricMeta?.Unit ?? ResolveUnitFromText(plan.TargetValue),
+                TargetValue = targetFallback,
+                Level = metricLevel,
+            },
+            StartDate = startDate.ToString("yyyy-MM-dd"),
+            EndDate = endDate.ToString("yyyy-MM-dd"),
+            Timeline = timeline,
+            Trend = series,
+        });
+    }
+
+    [HttpPost("submitApproval")]
+    public async Task<IActionResult> SubmitApproval([FromBody] ImprovementPlanIdInput input)
+    {
+        var plan = await LoadPlanAsync(input.Id) ?? throw Oops.Oh("改善计划不存在");
+        if (plan.Status is "approving" or "executing" or "pending_verify" or "closed")
+            return BadRequest(new { message = "当前状态不可提交审批" });
+
+        try
+        {
+            var instanceId = await _flowEngine.StartFlow(new StartFlowInput
+            {
+                BizType = SmartOpsImprovementBizHandler.BizTypeCode,
+                BizId = plan.Id,
+                BizNo = plan.PlanNo,
+                Title = $"智慧诊断改善计划-{plan.PlanNo}",
+                Comment = input.Comment,
+                BizData = new Dictionary<string, object>
+                {
+                    ["moduleCode"] = plan.ModuleCode,
+                    ["problemLevel"] = plan.ProblemLevel,
+                    ["problemName"] = plan.ProblemName,
+                    ["problemDept"] = plan.ProblemDept ?? "",
+                }
+            });
+            plan = await LoadPlanAsync(input.Id) ?? plan;
+            return Ok(new { instanceId, plan = ToOutput(plan) });
+        }
+        catch (Exception ex)
+        {
+            return BadRequest(new { message = $"提交审批失败:{ex.Message}" });
+        }
+    }
+
+    [HttpPost("startExecution")]
+    public async Task<IActionResult> StartExecution([FromBody] ImprovementPlanIdInput input)
+    {
+        var plan = await LoadPlanAsync(input.Id) ?? throw Oops.Oh("改善计划不存在");
+        if (plan.Status != "approved" && plan.Status != "executing")
+            return BadRequest(new { message = "审批通过后才能开始执行" });
+        await UpdateStatusAsync(plan.Id, "executing");
+        return Ok(ToOutput((await LoadPlanAsync(plan.Id))!));
+    }
+
+    [HttpPost("updateActionItem")]
+    public async Task<IActionResult> UpdateActionItem([FromBody] ImprovementActionItemUpdateInput input)
+    {
+        var plan = await LoadPlanAsync(input.Id) ?? throw Oops.Oh("改善计划不存在");
+        if (input.Index <= 0) return BadRequest(new { message = "行动项序号无效" });
+
+        var actions = ParseActionItems(plan.ActionItemsJson);
+        var index = input.Index - 1;
+        if (index < 0 || index >= actions.Count) return BadRequest(new { message = "行动项不存在" });
+
+        var status = NormalizeManualActionStatus(input.Status);
+        actions[index].Status = status;
+        actions[index].CompletedAt = ResolveActionCompletedAt(status, input.CompletedAt);
+
+        await _db.Updateable<AdoSmartOpsImprovementPlan>()
+            .SetColumns(x => x.ActionItemsJson == JsonSerializer.Serialize(actions))
+            .SetColumns(x => x.UpdateTime == DateTime.Now)
+            .Where(x => x.Id == plan.Id)
+            .ExecuteCommandAsync();
+
+        return Ok(ToOutput((await LoadPlanAsync(plan.Id))!));
+    }
+
+    [HttpPost("submitVerification")]
+    public async Task<IActionResult> SubmitVerification([FromBody] ImprovementVerificationInput input)
+    {
+        var plan = await LoadPlanAsync(input.Id) ?? throw Oops.Oh("改善计划不存在");
+        if (plan.Status is not ("executing" or "pending_verify"))
+            return BadRequest(new { message = "执行中计划才能提交验证" });
+
+        await _db.Updateable<AdoSmartOpsImprovementPlan>()
+            .SetColumns(x => x.Status == "pending_verify")
+            .SetColumns(x => x.VerifyResult == input.VerifyResult)
+            .SetColumns(x => x.VerifyValue == input.VerifyValue)
+            .SetColumns(x => x.VerifyRemark == input.VerifyRemark)
+            .SetColumns(x => x.UpdateTime == DateTime.Now)
+            .Where(x => x.Id == plan.Id)
+            .ExecuteCommandAsync();
+        return Ok(ToOutput((await LoadPlanAsync(plan.Id))!));
+    }
+
+    [HttpPost("close")]
+    public async Task<IActionResult> Close([FromBody] ImprovementPlanIdInput input)
+    {
+        var plan = await LoadPlanAsync(input.Id) ?? throw Oops.Oh("改善计划不存在");
+        if (plan.Status != "pending_verify")
+            return BadRequest(new { message = "提交验证后才能关闭" });
+        await UpdateStatusAsync(plan.Id, "closed");
+        return Ok(ToOutput((await LoadPlanAsync(plan.Id))!));
+    }
+
+    private async Task<AdoSmartOpsImprovementPlan?> LoadPlanAsync(long id)
+    {
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        return await _db.Queryable<AdoSmartOpsImprovementPlan>().FirstAsync(x => x.Id == id && x.TenantId == tenantId);
+    }
+
+    private async Task UpdateStatusAsync(long id, string status)
+    {
+        await _db.Updateable<AdoSmartOpsImprovementPlan>()
+            .SetColumns(x => x.Status == status)
+            .SetColumns(x => x.UpdateTime == DateTime.Now)
+            .Where(x => x.Id == id)
+            .ExecuteCommandAsync();
+    }
+
+    private async Task<string> GeneratePlanNoAsync()
+    {
+        var prefix = $"SOP-{DateTime.Now:yyyyMMdd}";
+        var count = await _db.Queryable<AdoSmartOpsImprovementPlan>()
+            .Where(x => x.PlanNo.StartsWith(prefix))
+            .CountAsync();
+        return $"{prefix}-{count + 1:000}";
+    }
+
+    private static object ToOutput(AdoSmartOpsImprovementPlan x)
+    {
+        return new
+        {
+            x.Id,
+            x.PlanNo,
+            x.ModuleCode,
+            x.MetricCode,
+            x.ProblemLevel,
+            x.ProblemMetricCode,
+            x.ProblemName,
+            x.ProblemDept,
+            x.TargetValue,
+            x.ActualValue,
+            x.GapLabel,
+            x.RootCause,
+            ActionItems = ParseActionItems(x.ActionItemsJson),
+            x.OwnerUserId,
+            DueDate = x.DueDate?.ToString("yyyy-MM-dd"),
+            x.Status,
+            x.FlowInstanceId,
+            x.VerifyResult,
+            x.VerifyValue,
+            x.VerifyRemark,
+            x.CreateTime,
+            x.UpdateTime,
+        };
+    }
+
+    private async Task<List<ImprovementVerificationTrendRow>> QueryVerificationTrendAsync(
+        string table,
+        AdoSmartOpsImprovementPlan plan,
+        string metricCode,
+        DateTime startDate,
+        DateTime endDate)
+    {
+        if (string.IsNullOrWhiteSpace(metricCode)) return new List<ImprovementVerificationTrendRow>();
+        var sql = $"""
+SELECT biz_date AS BizDate, metric_value AS MetricValue, target_value AS TargetValue
+FROM {table}
+WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code=@moduleCode
+  AND metric_code=@metricCode AND is_deleted=0 AND biz_date BETWEEN @startDate AND @endDate
+ORDER BY biz_date
+""";
+        return await _db.Ado.SqlQueryAsync<ImprovementVerificationTrendRow>(sql, new
+        {
+            tenantId = plan.TenantId,
+            factoryId = plan.FactoryId <= 0 ? 1 : plan.FactoryId,
+            moduleCode = plan.ModuleCode,
+            metricCode,
+            startDate,
+            endDate,
+        });
+    }
+
+    private static DateTime ResolveVerificationEndDate(AdoSmartOpsImprovementPlan plan, List<ImprovementActionItem> actions)
+    {
+        var candidates = new List<DateTime> { plan.CreateTime.Date };
+        if (plan.DueDate.HasValue) candidates.Add(plan.DueDate.Value.Date);
+        foreach (var action in actions)
+        {
+            if (DateTime.TryParse(action.DueDate, out var dueDate)) candidates.Add(dueDate.Date);
+            if (DateTime.TryParse(action.CompletedAt, out var completedAt)) candidates.Add(completedAt.Date);
+        }
+        return candidates.Max();
+    }
+
+    private static IEnumerable<DateTime> BuildDateRange(DateTime startDate, DateTime endDate)
+    {
+        for (var day = startDate.Date; day <= endDate.Date; day = day.AddDays(1))
+            yield return day;
+    }
+
+    private static ImprovementVerificationActionNode? BuildVerificationActionNode(ImprovementActionItem action, int index, DateTime day)
+    {
+        if (!DateTime.TryParse(action.DueDate, out var dueDate) || dueDate.Date != day.Date) return null;
+        var status = NormalizeActionStatus(action.Status, action.CompletedAt, dueDate.Date);
+        return new ImprovementVerificationActionNode
+        {
+            Index = index + 1,
+            Content = action.Content,
+            Owner = action.Owner,
+            DueDate = dueDate.ToString("yyyy-MM-dd"),
+            Status = status,
+            StatusLabel = ActionStatusLabel(status),
+            CompletedAt = DateTime.TryParse(action.CompletedAt, out var completedAt) ? completedAt.ToString("yyyy-MM-dd") : null,
+        };
+    }
+
+    private static string NormalizeActionStatus(string? status, string? completedAt, DateTime dueDate)
+    {
+        var normalized = (status ?? "").Trim().ToLowerInvariant();
+        if (normalized is "completed" or "done") return "completed";
+        if (normalized is "doing" or "in_progress") return "doing";
+        if (!string.IsNullOrWhiteSpace(completedAt)) return "completed";
+        if (dueDate < DateTime.Today) return "overdue";
+        return string.IsNullOrWhiteSpace(normalized) ? "pending" : normalized;
+    }
+
+    private static string NormalizeManualActionStatus(string? status)
+    {
+        var normalized = (status ?? "").Trim().ToLowerInvariant();
+        return normalized switch
+        {
+            "completed" or "done" => "completed",
+            "doing" or "in_progress" => "doing",
+            _ => "pending",
+        };
+    }
+
+    private static string? ResolveActionCompletedAt(string status, string? completedAt)
+    {
+        if (status != "completed") return null;
+        if (DateTime.TryParse(completedAt, out var value)) return value.ToString("yyyy-MM-dd");
+        return DateTime.Today.ToString("yyyy-MM-dd");
+    }
+
+    private static string ActionStatusLabel(string status)
+    {
+        return status switch
+        {
+            "completed" => "已完成",
+            "doing" => "进行中",
+            "overdue" => "逾期",
+            _ => "未开始",
+        };
+    }
+
+    private static int NormalizeMetricLevel(int problemLevel)
+    {
+        return problemLevel switch
+        {
+            <= 1 => 2,
+            2 => 3,
+            >= 3 => 4,
+        };
+    }
+
+    private static string ResolveKpiValueTable(int metricLevel)
+    {
+        return metricLevel switch
+        {
+            1 => "ado_s9_kpi_value_l1_day",
+            2 => "ado_s9_kpi_value_l2_day",
+            3 => "ado_s9_kpi_value_l3_day",
+            4 => "ado_s9_kpi_value_l4_day",
+            _ => "ado_s9_kpi_value_l2_day",
+        };
+    }
+
+    private static decimal? ParseDecimal(string? text)
+    {
+        if (string.IsNullOrWhiteSpace(text)) return null;
+        var digits = new string(text.Where(c => char.IsDigit(c) || c is '.' or '-').ToArray());
+        return decimal.TryParse(digits, NumberStyles.Any, CultureInfo.InvariantCulture, out var value) ? value : null;
+    }
+
+    private static string? ResolveUnitFromText(string? text)
+    {
+        if (string.IsNullOrWhiteSpace(text)) return null;
+        var unit = new string(text.Where(c => !char.IsDigit(c) && c is not '.' and not '-' and not '/').ToArray()).Trim();
+        return string.IsNullOrWhiteSpace(unit) ? null : unit;
+    }
+
+    private static List<ImprovementActionItem> ParseActionItems(string? json)
+    {
+        if (string.IsNullOrWhiteSpace(json)) return new List<ImprovementActionItem>();
+        try
+        {
+            return JsonSerializer.Deserialize<List<ImprovementActionItem>>(json) ?? new List<ImprovementActionItem>();
+        }
+        catch
+        {
+            return new List<ImprovementActionItem>();
+        }
+    }
+}
+
+public class ImprovementPlanCreateInput
+{
+    public long FactoryId { get; set; } = 1;
+    public string? ModuleCode { get; set; }
+    public string? MetricCode { get; set; }
+    public int ProblemLevel { get; set; }
+    public string? ProblemMetricCode { get; set; }
+    public string? ProblemName { get; set; }
+    public string? ProblemDept { get; set; }
+    public string? TargetValue { get; set; }
+    public string? ActualValue { get; set; }
+    public string? GapLabel { get; set; }
+    public string? RootCause { get; set; }
+    public List<ImprovementActionItem>? ActionItems { get; set; }
+    public long? OwnerUserId { get; set; }
+    public DateTime? DueDate { get; set; }
+}
+
+public class ImprovementActionItem
+{
+    public string Content { get; set; } = "";
+    public string? Owner { get; set; }
+    public string? DueDate { get; set; }
+    public string? Status { get; set; }
+    public string? CompletedAt { get; set; }
+}
+
+public class ImprovementPlanPageInput
+{
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 20;
+    public string? ModuleCode { get; set; }
+    public string? Status { get; set; }
+    public string? Keyword { get; set; }
+}
+
+public class ImprovementPlanIdInput
+{
+    public long Id { get; set; }
+    public string? Comment { get; set; }
+}
+
+public class ImprovementVerificationInput
+{
+    public long Id { get; set; }
+    public string? VerifyResult { get; set; }
+    public string? VerifyValue { get; set; }
+    public string? VerifyRemark { get; set; }
+}
+
+public class ImprovementActionItemUpdateInput
+{
+    public long Id { get; set; }
+    public int Index { get; set; }
+    public string? Status { get; set; }
+    public string? CompletedAt { get; set; }
+}
+
+public class ImprovementVerificationActionNode
+{
+    public int Index { get; set; }
+    public string Content { get; set; } = "";
+    public string? Owner { get; set; }
+    public string? DueDate { get; set; }
+    public string Status { get; set; } = "pending";
+    public string StatusLabel { get; set; } = "未开始";
+    public string? CompletedAt { get; set; }
+}
+
+public class ImprovementVerificationTrendRow
+{
+    public DateTime BizDate { get; set; }
+    public decimal? MetricValue { get; set; }
+    public decimal? TargetValue { get; set; }
+}

+ 53 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AdoSmartOpsKpiMasterController.cs

@@ -113,10 +113,10 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
     {
         if (string.IsNullOrWhiteSpace(dto.MetricName))
             return BadRequest(new { message = "指标名称不能为空" });
-        if (dto.MetricLevel < 1 || dto.MetricLevel > 3)
-            return BadRequest(new { message = "层级必须为 1/2/3" });
+        if (dto.MetricLevel < 1 || dto.MetricLevel > 4)
+            return BadRequest(new { message = "层级必须为 1/2/3/4" });
         if (dto.MetricLevel > 1 && dto.ParentId == null)
-            return BadRequest(new { message = "L2/L3 必须指定父指标" });
+            return BadRequest(new { message = "L2/L3/L4 必须指定父指标" });
 
         var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
 
@@ -175,6 +175,10 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
         if (entity == null) return NotFound();
 
         var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        if (dto.MetricLevel < 1 || dto.MetricLevel > 4)
+            return BadRequest(new { message = "层级必须为 1/2/3/4" });
+        if (dto.MetricLevel > 1 && dto.ParentId == null)
+            return BadRequest(new { message = "L2/L3/L4 必须指定父指标" });
 
         // Q4-C:公式解析 + 校验
         var fxResult = await ProcessFormulaAsync(tenantId, dto.FormulaExpr, dto.IsEnabled, excludeKpiId: id);
@@ -340,7 +344,18 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
         var parsed = new List<DemoImportRow>();
         foreach (var m in masters)
         {
-            if (excelOverrides.TryGetValue(m.MetricCode, out var ov))
+            if (TryGetFixedS1DiagnosisDemoValue(m.MetricCode, out var fixedValue))
+            {
+                parsed.Add(new DemoImportRow
+                {
+                    MetricCode = m.MetricCode, Master = m,
+                    Target = fixedValue.Target, Actual = fixedValue.Actual,
+                    HasTarget = true, HasActual = true,
+                    YellowThreshold = null, RedThreshold = null,
+                    FixedStatus = fixedValue.Status,
+                });
+            }
+            else if (excelOverrides.TryGetValue(m.MetricCode, out var ov))
             {
                 var tgt = ov.HasTarget ? ov.Target : ov.HasActual ? ov.Actual * 1.05m : AutoTarget(m, rng);
                 var act = ov.HasActual ? ov.Actual : ov.HasTarget ? ov.Target * AutoActualFactor(m, rng) : AutoActual(AutoTarget(m, rng), m, rng);
@@ -383,18 +398,23 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
         var l1 = parsed.Where(p => p.Master.MetricLevel == 1).ToList();
         var l2 = parsed.Where(p => p.Master.MetricLevel == 2).ToList();
         var l3 = parsed.Where(p => p.Master.MetricLevel == 3).ToList();
+        var l4 = parsed.Where(p => p.Master.MetricLevel == 4).ToList();
 
+        AidopTenantMigration.EnsureKpiValueL4Table(_db);
         await _db.Ado.ExecuteCommandAsync(
             $"DELETE FROM ado_s9_kpi_value_l1_day WHERE tenant_id = {tenantId}");
         await _db.Ado.ExecuteCommandAsync(
             $"DELETE FROM ado_s9_kpi_value_l2_day WHERE tenant_id = {tenantId}");
         await _db.Ado.ExecuteCommandAsync(
             $"DELETE FROM ado_s9_kpi_value_l3_day WHERE tenant_id = {tenantId}");
+        await _db.Ado.ExecuteCommandAsync(
+            $"DELETE FROM ado_s9_kpi_value_l4_day WHERE tenant_id = {tenantId}");
 
-        long seq = 0;
         async Task InsertLevel(List<DemoImportRow> items, string table)
         {
             if (items.Count == 0) return;
+            var maxIdValue = await _db.Ado.GetScalarAsync($"SELECT COALESCE(MAX(id), 0) FROM `{table}`");
+            var nextId = Convert.ToInt64(maxIdValue);
             var levelRng = new Random(42);
             var batchValues = new List<string>();
             foreach (var p in items)
@@ -406,7 +426,7 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
 
                 for (int i = 0; i < DAYS; i++)
                 {
-                    seq++;
+                    nextId++;
                     var d = startDate.AddDays(i);
                     decimal val;
                     if (i == DAYS - 1)
@@ -417,7 +437,9 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
                         val = Math.Max(0, p.Actual * (1 + noise));
                     }
                     val = Math.Round(val, 4);
-                    var sc = AidopS4KpiMerge.AchievementLevel(val, p.Target, dir, yTh, rTh);
+                    var sc = i == DAYS - 1 && !string.IsNullOrWhiteSpace(p.FixedStatus)
+                        ? p.FixedStatus
+                        : AidopS4KpiMerge.AchievementLevel(val, p.Target, dir, yTh, rTh);
                     var tf = "flat";
                     if (i > 0)
                     {
@@ -427,7 +449,7 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
                     prevVal = val;
 
                     batchValues.Add(
-                        $"({seq},{tenantId},1,'{d:yyyy-MM-dd}'," +
+                        $"({nextId},{tenantId},1,'{d:yyyy-MM-dd}'," +
                         $"'{p.Master.ModuleCode}','{p.MetricCode}',{val},{p.Target},'{sc}','{tf}',0,1)");
                 }
             }
@@ -447,6 +469,7 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
         await InsertLevel(l1, "ado_s9_kpi_value_l1_day");
         await InsertLevel(l2, "ado_s9_kpi_value_l2_day");
         await InsertLevel(l3, "ado_s9_kpi_value_l3_day");
+        await InsertLevel(l4, "ado_s9_kpi_value_l4_day");
 
         // ---- 同步 LayoutItem / HomeModule(只补不改,保护已有布局配置) ----
         // 背景:
@@ -530,6 +553,7 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
                 l1 = l1.Count,
                 l2 = l2.Count,
                 l3 = l3.Count,
+                l4 = l4.Count,
                 days = DAYS,
                 fromExcel,
                 autoGen = parsed.Count - fromExcel,
@@ -591,6 +615,7 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
         public bool HasActual { get; set; }
         public decimal? YellowThreshold { get; set; }
         public decimal? RedThreshold { get; set; }
+        public string? FixedStatus { get; set; }
     }
 
     private static decimal AutoTarget(AdoSmartOpsKpiMaster m, Random rng)
@@ -613,6 +638,26 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
         return Math.Round(target * factor, 4);
     }
 
+    private static bool TryGetFixedS1DiagnosisDemoValue(string? metricCode, out (decimal Target, decimal Actual, string Status) value)
+    {
+        value = default;
+        if (string.IsNullOrWhiteSpace(metricCode)) return false;
+        var fixedValues = new Dictionary<string, (decimal Target, decimal Actual, string Status)>(StringComparer.OrdinalIgnoreCase)
+        {
+            ["S1_L2_004"] = (5m, 6.2m, "red"),
+            ["S1_L3_201"] = (8m, 14.4m, "red"),
+            ["S1_L3_202"] = (12m, 14.2m, "yellow"),
+            ["S1_L3_203"] = (8m, 7m, "green"),
+            ["S1_L3_204"] = (10m, 12m, "yellow"),
+            ["S1_L3_205"] = (2m, 2m, "green"),
+            ["S1_L4_201"] = (2m, 2m, "green"),
+            ["S1_L4_202"] = (3m, 9.2m, "red"),
+            ["S1_L4_203"] = (1m, 1.2m, "yellow"),
+            ["S1_L4_204"] = (2m, 2m, "green"),
+        };
+        return fixedValues.TryGetValue(metricCode, out value);
+    }
+
     private static decimal AutoActualFactor(AdoSmartOpsKpiMaster m, Random rng)
     {
         return (m.Direction == "lower_is_better")

+ 596 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.DashboardPage.cs

@@ -0,0 +1,596 @@
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.Entity;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers;
+
+public partial class AidopKanbanController
+{
+    private static readonly HashSet<string> DynamicDashboardModules = new(StringComparer.OrdinalIgnoreCase)
+    {
+        "S1", "S2", "S3", "S4", "S5", "S6", "S7", "S9"
+    };
+
+    /// <summary>
+    /// 通用动态详情看板:返回页面区域、组件配置、指标定义、当前值和趋势。
+    /// </summary>
+    [HttpGet("dashboard-page/{moduleCode}")]
+    public async Task<IActionResult> GetDashboardPage(string moduleCode, [FromQuery] long factoryId = 1)
+    {
+        var mc = NormalizeDynamicDashboardModule(moduleCode);
+        if (mc == null) return BadRequest(new { message = "moduleCode 仅支持 S1/S2/S3/S4/S5/S6/S7/S9" });
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+
+        var kpis = await _db.Queryable<AdoSmartOpsKpiMaster>()
+            .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled)
+            .OrderBy(x => x.MetricLevel)
+            .OrderBy(x => x.SortNo)
+            .ToListAsync();
+        var kpiByCode = kpis.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
+        var pageConfig = (await _db.Queryable<AdoSmartOpsDashboardPageConfig>()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
+            .ToListAsync()).FirstOrDefault();
+
+        var persistedWidgets = await _db.Queryable<AdoSmartOpsDashboardWidget>()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc && x.IsEnabled == 1)
+            .OrderBy(x => x.SortNo)
+            .ToListAsync();
+        var widgets = persistedWidgets.Count > 0
+            ? persistedWidgets.Select(x => MapPersistedWidget(x, kpiByCode)).Where(x => x.MetricCodes.Count > 0).ToList()
+            : BuildDefaultDashboardWidgets(mc, kpis);
+
+        var values = await LoadDashboardValuesAsync(tenantId, factoryId, mc);
+        var sections = BuildDashboardSections(widgets, values, kpiByCode);
+        var tabs = BuildDashboardTabs(kpis);
+        var navigationMode = ResolveNavigationMode(pageConfig?.NavigationMode, tabs.Count, mc);
+
+        return Ok(new
+        {
+            moduleCode = mc,
+            title = $"{mc} {ResolveDashboardModuleName(mc)}",
+            subtitle = "动态运营指标看板",
+            layoutMode = "section_grid",
+            navigationMode,
+            tabSource = pageConfig?.TabSource ?? "l1_metrics",
+            defaultTabMetricCode = pageConfig?.DefaultTabMetricCode ?? tabs.FirstOrDefault()?.MetricCode,
+            tabs,
+            dataSource = new
+            {
+                metricDefinition = "ado_smart_ops_kpi_master",
+                valueTables = new[] { "ado_s9_kpi_value_l1_day", "ado_s9_kpi_value_l2_day", "ado_s9_kpi_value_l3_day", "ado_s9_kpi_value_l4_day" },
+                formulaStatus = "formula_expr_validated_pending_runtime_engine"
+            },
+            metrics = kpis.Select(x => BuildDashboardMetricPayload(x, values)).ToList(),
+            sections
+        });
+    }
+
+    /// <summary>
+    /// 保存动态详情看板组件配置。前端配置页可逐步接入,当前 S1 默认布局无需先保存。
+    /// </summary>
+    [HttpPut("dashboard-page/{moduleCode}")]
+    public async Task<IActionResult> PutDashboardPage(string moduleCode, [FromBody] DashboardPageSaveDto? body, [FromQuery] long factoryId = 1)
+    {
+        var mc = NormalizeDynamicDashboardModule(moduleCode);
+        if (mc == null) return BadRequest(new { message = "moduleCode 仅支持 S1/S2/S3/S4/S5/S6/S7/S9" });
+        if (body?.Widgets == null) return BadRequest(new { message = "widgets 不能为空" });
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+
+        var kpiCodes = await _db.Queryable<AdoSmartOpsKpiMaster>()
+            .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled)
+            .Select(x => x.MetricCode)
+            .ToListAsync();
+        var validCodes = new HashSet<string>(kpiCodes, StringComparer.OrdinalIgnoreCase);
+
+        var now = DateTime.Now;
+        var rows = new List<AdoSmartOpsDashboardWidget>();
+        var sort = 0;
+        foreach (var w in body.Widgets)
+        {
+            var metricCodes = (w.MetricCodes ?? new List<string>())
+                .Where(x => !string.IsNullOrWhiteSpace(x))
+                .Select(x => x.Trim())
+                .Distinct(StringComparer.OrdinalIgnoreCase)
+                .ToList();
+            if (metricCodes.Count == 0) continue;
+            var invalid = metricCodes.FirstOrDefault(x => !validCodes.Contains(x));
+            if (invalid != null) return BadRequest(new { message = $"无效指标编码: {invalid}" });
+
+            sort += 10;
+            rows.Add(new AdoSmartOpsDashboardWidget
+            {
+                TenantId = tenantId,
+                FactoryId = factoryId,
+                ModuleCode = mc,
+                SectionCode = NormalizeSectionCode(w.SectionCode),
+                TabMetricCode = string.IsNullOrWhiteSpace(w.TabMetricCode) ? null : w.TabMetricCode.Trim(),
+                WidgetCode = string.IsNullOrWhiteSpace(w.WidgetCode) ? $"{mc}-W-{sort}" : w.WidgetCode.Trim(),
+                WidgetType = NormalizeWidgetType(w.WidgetType),
+                Title = string.IsNullOrWhiteSpace(w.Title) ? null : w.Title.Trim(),
+                MetricLevel = Math.Max(1, Math.Min(4, w.MetricLevel)),
+                MetricCodesJson = JsonSerializer.Serialize(metricCodes),
+                Span = NormalizeSpan(w.Span),
+                SortNo = w.SortNo > 0 ? w.SortNo : sort,
+                StyleJson = string.IsNullOrWhiteSpace(w.StyleJson) ? null : w.StyleJson,
+                IsEnabled = 1,
+                UpdateTime = now
+            });
+        }
+
+        try
+        {
+            _db.Ado.BeginTran();
+            var navigationMode = NormalizeNavigationMode(body.NavigationMode);
+            var tabSource = string.IsNullOrWhiteSpace(body.TabSource) ? "l1_metrics" : body.TabSource.Trim();
+            var defaultTab = string.IsNullOrWhiteSpace(body.DefaultTabMetricCode) ? null : body.DefaultTabMetricCode.Trim();
+            var pageRows = await _db.Updateable<AdoSmartOpsDashboardPageConfig>()
+                .SetColumns(x => new AdoSmartOpsDashboardPageConfig
+                {
+                    NavigationMode = navigationMode,
+                    TabSource = tabSource,
+                    DefaultTabMetricCode = defaultTab,
+                    Theme = "dark",
+                    UpdateTime = now
+                })
+                .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
+                .ExecuteCommandAsync();
+            if (pageRows == 0)
+            {
+                await _db.Insertable(new AdoSmartOpsDashboardPageConfig
+                {
+                    TenantId = tenantId,
+                    FactoryId = factoryId,
+                    ModuleCode = mc,
+                    NavigationMode = navigationMode,
+                    TabSource = tabSource,
+                    DefaultTabMetricCode = defaultTab,
+                    Theme = "dark",
+                    UpdateTime = now
+                }).ExecuteCommandAsync();
+            }
+
+            await _db.Deleteable<AdoSmartOpsDashboardWidget>()
+                .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
+                .ExecuteCommandAsync();
+            if (rows.Count > 0) await _db.Insertable(rows).ExecuteCommandAsync();
+            _db.Ado.CommitTran();
+        }
+        catch (Exception ex)
+        {
+            _db.Ado.RollbackTran();
+            return BadRequest(new { message = ex.Message });
+        }
+
+        return Ok(new { ok = true, count = rows.Count });
+    }
+
+    private static string? NormalizeDynamicDashboardModule(string moduleCode)
+    {
+        var mc = (moduleCode ?? "").Trim().ToUpperInvariant();
+        return DynamicDashboardModules.Contains(mc) ? mc : null;
+    }
+
+    private static string ResolveDashboardModuleName(string moduleCode) => moduleCode.ToUpperInvariant() switch
+    {
+        "S1" => "产销协同看板",
+        "S2" => "订单排程看板",
+        "S3" => "供应协同看板",
+        "S4" => "采购执行看板",
+        "S5" => "物料仓储看板",
+        "S6" => "生产执行看板",
+        "S7" => "成品仓储看板",
+        "S9" => "运营指标看板",
+        _ => "运营看板"
+    };
+
+    private static string NormalizeSectionCode(string? sectionCode)
+    {
+        var s = (sectionCode ?? "").Trim().ToLowerInvariant();
+        return s is "overview" or "process_efficiency" or "quality_trend" or "resource_load"
+            or "inventory_warning" or "breakdown" or "trend" or "detail" ? s : "overview";
+    }
+
+    private static string NormalizeWidgetType(string? widgetType)
+    {
+        var s = (widgetType ?? "").Trim().ToLowerInvariant();
+        return s is "metric_card" or "traffic_light_card" or "progress_card" or "gauge"
+            or "trend_line" or "line_trend" or "bar_compare" or "funnel_chart" or "heatmap"
+            or "threshold_line" or "area_chart" or "metric_tree" or "metric_table" ? s : "metric_card";
+    }
+
+    private static string NormalizeNavigationMode(string? navigationMode)
+    {
+        var s = (navigationMode ?? "").Trim().ToLowerInvariant();
+        return s is "tabs_by_l1" or "l1_cards" or "single_page" or "auto" ? s : "auto";
+    }
+
+    private static string ResolveNavigationMode(string? navigationMode, int tabCount, string moduleCode)
+    {
+        var mode = NormalizeNavigationMode(navigationMode);
+        if (mode != "auto") return mode;
+        if (string.Equals(moduleCode, "S1", StringComparison.OrdinalIgnoreCase) && tabCount > 0) return "l1_cards";
+        return tabCount > 3 ? "tabs_by_l1" : "single_page";
+    }
+
+    private static int NormalizeSpan(int span)
+    {
+        if (span <= 0) return 6;
+        if (span > 24) return 24;
+        return span;
+    }
+
+    private static DashboardWidgetModel MapPersistedWidget(AdoSmartOpsDashboardWidget row, Dictionary<string, AdoSmartOpsKpiMaster> kpiByCode)
+    {
+        var codes = ParseMetricCodes(row.MetricCodesJson)
+            .Where(kpiByCode.ContainsKey)
+            .ToList();
+        return new DashboardWidgetModel(
+            row.SectionCode,
+            row.TabMetricCode,
+            row.WidgetCode,
+            row.WidgetType,
+            string.IsNullOrWhiteSpace(row.Title) && codes.Count > 0 ? kpiByCode[codes[0]].MetricName : row.Title,
+            row.MetricLevel,
+            codes,
+            row.Span,
+            row.SortNo,
+            row.StyleJson);
+    }
+
+    private static List<DashboardWidgetModel> BuildDefaultDashboardWidgets(string moduleCode, List<AdoSmartOpsKpiMaster> kpis)
+    {
+        var result = new List<DashboardWidgetModel>();
+        var l1 = kpis.Where(x => x.MetricLevel == 1).OrderBy(x => x.SortNo).Take(8).ToList();
+        var l2 = kpis.Where(x => x.MetricLevel == 2).OrderBy(x => x.SortNo).ToList();
+
+        var sort = 0;
+        foreach (var k in l1)
+        {
+            sort += 10;
+            var widgetType = ResolveDefaultWidgetType(k);
+            result.Add(new DashboardWidgetModel(
+                "overview",
+                k.MetricCode,
+                $"{moduleCode}-OV-{k.MetricCode}",
+                widgetType,
+                k.MetricName,
+                1,
+                new List<string> { k.MetricCode },
+                widgetType == "gauge" ? 6 : 6,
+                sort,
+                null));
+        }
+
+        foreach (var group in l2.GroupBy(x => x.ParentId).OrderBy(g => g.Min(x => x.SortNo)))
+        {
+            sort += 10;
+            var metricCodes = group.OrderBy(x => x.SortNo).Select(x => x.MetricCode).ToList();
+            var parent = group.Key == null ? null : kpis.FirstOrDefault(x => x.Id == group.Key);
+            var parentName = parent?.MetricName ?? "未分组指标";
+            var tabMetricCode = parent?.MetricCode;
+            var sectionCode = ResolveDefaultSection(parentName);
+            result.Add(new DashboardWidgetModel(
+                sectionCode,
+                tabMetricCode,
+                $"{moduleCode}-BD-{group.Key ?? 0}",
+                ResolveDefaultAnalysisWidgetType(parentName),
+                parentName,
+                2,
+                metricCodes,
+                sectionCode == "process_efficiency" ? 12 : 8,
+                sort,
+                null));
+        }
+
+        foreach (var k in l1)
+        {
+            sort += 10;
+            result.Add(new DashboardWidgetModel(
+                ResolveDefaultSection(k.MetricName),
+                k.MetricCode,
+                $"{moduleCode}-TR-{k.MetricCode}",
+                ResolveDefaultTrendWidgetType(k.MetricName),
+                $"{k.MetricName}趋势",
+                1,
+                new List<string> { k.MetricCode },
+                12,
+                sort,
+                null));
+        }
+
+        return result;
+    }
+
+    private static string ResolveDefaultWidgetType(AdoSmartOpsKpiMaster kpi)
+    {
+        var name = kpi.MetricName ?? "";
+        if (name.Contains("满足率") || name.Contains("达成率") || name.Contains("合格率")) return "gauge";
+        if (name.Contains("周期") || name.Contains("周转")) return "progress_card";
+        return "metric_card";
+    }
+
+    private static string ResolveDefaultSection(string? metricName)
+    {
+        var name = metricName ?? "";
+        if (name.Contains("满足率") || name.Contains("达成率") || name.Contains("质量")) return "quality_trend";
+        if (name.Contains("人数") || name.Contains("人效") || name.Contains("负荷")) return "resource_load";
+        if (name.Contains("库存") || name.Contains("周转")) return "inventory_warning";
+        if (name.Contains("周期") || name.Contains("评审") || name.Contains("流程")) return "process_efficiency";
+        return "breakdown";
+    }
+
+    private static string ResolveDefaultAnalysisWidgetType(string? metricName)
+    {
+        var name = metricName ?? "";
+        if (name.Contains("满足率") || name.Contains("达成率")) return "line_trend";
+        if (name.Contains("人数") || name.Contains("人效") || name.Contains("负荷")) return "heatmap";
+        if (name.Contains("库存") || name.Contains("周转")) return "threshold_line";
+        if (name.Contains("周期") || name.Contains("评审")) return "bar_compare";
+        return "metric_tree";
+    }
+
+    private static string ResolveDefaultTrendWidgetType(string? metricName)
+    {
+        var name = metricName ?? "";
+        if (name.Contains("库存")) return "area_chart";
+        if (name.Contains("周转")) return "threshold_line";
+        return "line_trend";
+    }
+
+    private async Task<Dictionary<string, DashboardMetricValueModel>> LoadDashboardValuesAsync(long tenantId, long factoryId, string moduleCode)
+    {
+        var result = new Dictionary<string, DashboardMetricValueModel>(StringComparer.OrdinalIgnoreCase);
+        AidopTenantMigration.EnsureKpiValueL4Table(_db);
+        foreach (var (table, level) in new[]
+        {
+            ("ado_s9_kpi_value_l1_day", 1),
+            ("ado_s9_kpi_value_l2_day", 2),
+            ("ado_s9_kpi_value_l3_day", 3),
+            ("ado_s9_kpi_value_l4_day", 4)
+        })
+        {
+            var bizDate = await ResolveMaxBizDateGenericAsync(table, tenantId, factoryId, moduleCode);
+            var currentSql = $"""
+SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue, status_color AS StatusColor, trend_flag AS TrendFlag
+FROM {table}
+WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code=@moduleCode AND is_deleted=0 AND biz_date=@bizDate
+""";
+            var rows = await _db.Ado.SqlQueryAsync<DashboardValueRow>(currentSql, new { tenantId, factoryId, moduleCode, bizDate });
+
+            var trendSql = $"""
+SELECT metric_code AS MetricCode, biz_date AS BizDate, metric_value AS MetricValue
+FROM {table}
+WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code=@moduleCode AND is_deleted=0
+  AND biz_date >= DATE_SUB(@bizDate, INTERVAL 13 DAY)
+ORDER BY metric_code, biz_date
+""";
+            var trends = await _db.Ado.SqlQueryAsync<DashboardTrendRow>(trendSql, new { tenantId, factoryId, moduleCode, bizDate });
+            var trendByMetric = trends
+                .Where(x => !string.IsNullOrWhiteSpace(x.MetricCode))
+                .GroupBy(x => x.MetricCode!, StringComparer.OrdinalIgnoreCase)
+                .ToDictionary(
+                    x => x.Key,
+                    x => x.Select(t => new DashboardTrendPointModel(t.BizDate.ToString("yyyy-MM-dd"), t.MetricValue)).ToList(),
+                    StringComparer.OrdinalIgnoreCase);
+
+            foreach (var row in rows.Where(x => !string.IsNullOrWhiteSpace(x.MetricCode)))
+            {
+                result[row.MetricCode!] = new DashboardMetricValueModel(
+                    level,
+                    row.MetricValue,
+                    row.TargetValue,
+                    row.StatusColor,
+                    row.TrendFlag,
+                    trendByMetric.TryGetValue(row.MetricCode!, out var pts) ? pts : new List<DashboardTrendPointModel>());
+            }
+        }
+
+        return result;
+    }
+
+    private static List<object> BuildDashboardSections(
+        List<DashboardWidgetModel> widgets,
+        Dictionary<string, DashboardMetricValueModel> values,
+        Dictionary<string, AdoSmartOpsKpiMaster> kpiByCode)
+    {
+        var sectionMeta = new Dictionary<string, (string Title, int Sort)>(StringComparer.OrdinalIgnoreCase)
+        {
+            ["overview"] = ("核心指标", 10),
+            ["process_efficiency"] = ("流程效率分析", 20),
+            ["quality_trend"] = ("质量与满意度趋势", 30),
+            ["resource_load"] = ("人效与资源负荷", 40),
+            ["inventory_warning"] = ("库存预警", 50),
+            ["breakdown"] = ("指标分解", 60),
+            ["trend"] = ("趋势分析", 70),
+            ["detail"] = ("明细列表", 80)
+        };
+
+        return widgets
+            .GroupBy(x => NormalizeSectionCode(x.SectionCode))
+            .OrderBy(x => sectionMeta.TryGetValue(x.Key, out var meta) ? meta.Sort : 99)
+            .Select(g =>
+            {
+                var meta = sectionMeta.TryGetValue(g.Key, out var m) ? m : (g.Key, 99);
+                return new
+                {
+                    sectionCode = g.Key,
+                    title = meta.Item1,
+                    layout = g.Key == "overview" ? "card_grid" : "panel_grid",
+                    widgets = g.OrderBy(x => x.SortNo).Select(w => BuildDashboardWidgetPayload(w, values, kpiByCode)).ToList()
+                };
+            })
+            .Cast<object>()
+            .ToList();
+    }
+
+    private static object BuildDashboardWidgetPayload(
+        DashboardWidgetModel widget,
+        Dictionary<string, DashboardMetricValueModel> values,
+        Dictionary<string, AdoSmartOpsKpiMaster> kpiByCode)
+    {
+        var metrics = widget.MetricCodes
+            .Where(kpiByCode.ContainsKey)
+            .Select(code => BuildDashboardMetricPayload(kpiByCode[code], values))
+            .ToList();
+
+        return new
+        {
+            sectionCode = widget.SectionCode,
+            tabMetricCode = widget.TabMetricCode,
+            widgetCode = widget.WidgetCode,
+            widgetType = widget.WidgetType,
+            title = widget.Title,
+            metricLevel = widget.MetricLevel,
+            span = widget.Span,
+            sortNo = widget.SortNo,
+            style = ParseStyleJson(widget.StyleJson),
+            metrics
+        };
+    }
+
+    private static object BuildDashboardMetricPayload(
+        AdoSmartOpsKpiMaster kpi,
+        Dictionary<string, DashboardMetricValueModel> values)
+    {
+        values.TryGetValue(kpi.MetricCode, out var value);
+        var level = value?.Level ?? kpi.MetricLevel;
+        var achievement = AidopS4KpiMerge.AchievementLevel(
+            value?.MetricValue,
+            value?.TargetValue,
+            kpi.Direction ?? "higher_is_better",
+            kpi.YellowThreshold,
+            kpi.RedThreshold);
+        var gap = AidopS4KpiMerge.GapValue(value?.MetricValue, value?.TargetValue);
+        return new
+        {
+            id = kpi.Id,
+            metricCode = kpi.MetricCode,
+            metricName = kpi.MetricName,
+            metricLevel = level,
+            parentId = kpi.ParentId,
+            unit = kpi.Unit ?? "",
+            direction = kpi.Direction,
+            formula = kpi.Formula,
+            formulaExpr = kpi.FormulaExpr,
+            formulaPreview = kpi.FormulaPreview,
+            calcRule = kpi.CalcRule,
+            dataSource = kpi.DataSource,
+            department = kpi.Department,
+            currentValue = value?.MetricValue,
+            targetValue = value?.TargetValue,
+            statusColor = achievement,
+            isBlink = achievement == "red" && IsBeyondRedThreshold(value?.MetricValue, value?.TargetValue, kpi.Direction),
+            trendFlag = value?.TrendFlag,
+            gapValue = gap,
+            gapLabel = FormatGapLabelGeneric(gap, kpi.Unit),
+            trend = value?.Trend ?? new List<DashboardTrendPointModel>()
+        };
+    }
+
+    private static List<string> ParseMetricCodes(string? json)
+    {
+        if (string.IsNullOrWhiteSpace(json)) return new List<string>();
+        try
+        {
+            return JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>();
+        }
+        catch
+        {
+            return json.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
+        }
+    }
+
+    private static object ParseStyleJson(string? json)
+    {
+        if (string.IsNullOrWhiteSpace(json)) return new { };
+        try
+        {
+            return JsonSerializer.Deserialize<object>(json) ?? new { };
+        }
+        catch
+        {
+            return new { raw = json };
+        }
+    }
+
+    private static bool IsBeyondRedThreshold(decimal? current, decimal? target, string? direction)
+    {
+        if (current == null || target == null || target == 0) return false;
+        var ratio = current.Value / target.Value * 100m;
+        return string.Equals(direction, "lower_is_better", StringComparison.OrdinalIgnoreCase)
+            ? ratio > 120m
+            : ratio < 80m;
+    }
+
+    private static List<DashboardTabModel> BuildDashboardTabs(List<AdoSmartOpsKpiMaster> kpis)
+    {
+        return kpis
+            .Where(x => x.MetricLevel == 1)
+            .OrderBy(x => x.SortNo)
+            .Select(x => new DashboardTabModel(x.MetricCode, x.MetricName, x.SortNo))
+            .ToList();
+    }
+
+    public class DashboardPageSaveDto
+    {
+        public string? NavigationMode { get; set; }
+        public string? TabSource { get; set; }
+        public string? DefaultTabMetricCode { get; set; }
+        public List<DashboardWidgetSaveDto>? Widgets { get; set; }
+    }
+
+    public class DashboardWidgetSaveDto
+    {
+        public string? SectionCode { get; set; }
+        public string? TabMetricCode { get; set; }
+        public string? WidgetCode { get; set; }
+        public string? WidgetType { get; set; }
+        public string? Title { get; set; }
+        public int MetricLevel { get; set; }
+        public List<string>? MetricCodes { get; set; }
+        public int Span { get; set; }
+        public int SortNo { get; set; }
+        public string? StyleJson { get; set; }
+    }
+
+    private sealed record DashboardWidgetModel(
+        string SectionCode,
+        string? TabMetricCode,
+        string WidgetCode,
+        string WidgetType,
+        string? Title,
+        int MetricLevel,
+        List<string> MetricCodes,
+        int Span,
+        int SortNo,
+        string? StyleJson);
+
+    private sealed record DashboardTabModel(string MetricCode, string Title, int SortNo);
+
+    private sealed record DashboardMetricValueModel(
+        int Level,
+        decimal? MetricValue,
+        decimal? TargetValue,
+        string? StatusColor,
+        string? TrendFlag,
+        List<DashboardTrendPointModel> Trend);
+
+    private sealed record DashboardTrendPointModel(string Date, decimal? Value);
+
+    private sealed class DashboardValueRow
+    {
+        public string? MetricCode { get; set; }
+        public decimal? MetricValue { get; set; }
+        public decimal? TargetValue { get; set; }
+        public string? StatusColor { get; set; }
+        public string? TrendFlag { get; set; }
+    }
+
+    private sealed class DashboardTrendRow
+    {
+        public string? MetricCode { get; set; }
+        public DateTime BizDate { get; set; }
+        public decimal? MetricValue { get; set; }
+    }
+}

+ 200 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Diagnosis.cs

@@ -0,0 +1,200 @@
+using Admin.NET.Plugin.AiDOP.Entity;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers;
+
+public partial class AidopKanbanController
+{
+    /// <summary>
+    /// 智慧诊断:按模块和当前 L1 指标返回 L2/L3/L4 诊断矩阵。
+    /// </summary>
+    [HttpGet("smart-diagnosis/{moduleCode}")]
+    public async Task<IActionResult> GetSmartDiagnosis(string moduleCode, [FromQuery] string? metricCode = null, [FromQuery] long factoryId = 1)
+    {
+        var mc = NormalizeDynamicDashboardModule(moduleCode);
+        if (mc == null) return BadRequest(new { message = "moduleCode 仅支持 S1/S2/S3/S4/S5/S6/S7/S9" });
+
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        var kpis = await _db.Queryable<AdoSmartOpsKpiMaster>()
+            .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled)
+            .OrderBy(x => x.MetricLevel)
+            .OrderBy(x => x.SortNo)
+            .ToListAsync();
+        var values = await LoadDashboardValuesAsync(tenantId, factoryId, mc);
+        var l1Metrics = kpis.Where(x => x.MetricLevel == 1).OrderBy(x => x.SortNo).ToList();
+        var root = ResolveDiagnosisRoot(l1Metrics, values, metricCode);
+        if (root == null) return Ok(new { moduleCode = mc, metricCode, title = $"{mc} 智慧诊断", stages = Array.Empty<object>() });
+
+        var children = kpis
+            .Where(x => x.MetricLevel == 2 && x.ParentId == root.Id)
+            .OrderBy(x => x.SortNo)
+            .ToList();
+        if (children.Count == 0)
+        {
+            children = kpis.Where(x => x.MetricLevel == 2).OrderBy(x => x.SortNo).Take(5).ToList();
+        }
+
+        var stagePayload = children.Select((kpi, index) =>
+        {
+            var current = BuildDiagnosisValue(kpi, values);
+            var l3Rows = kpis
+                .Where(x => x.MetricLevel == 3 && x.ParentId == kpi.Id)
+                .OrderBy(x => x.SortNo)
+                .ToList();
+            var l4Rows = l3Rows.ToDictionary(
+                x => x.MetricCode,
+                x => kpis
+                    .Where(y => y.MetricLevel == 4 && y.ParentId == x.Id)
+                    .OrderBy(y => y.SortNo)
+                    .Select(y => BuildDiagnosisNode(y, values))
+                    .ToList(),
+                StringComparer.OrdinalIgnoreCase);
+            return new
+            {
+                key = kpi.MetricCode,
+                name = kpi.MetricName,
+                flowCaption = kpi.MetricName,
+                dept = string.IsNullOrWhiteSpace(kpi.Department) ? "未配置" : kpi.Department,
+                kpi = FormatDiagnosisValue(current.Target, kpi.Unit),
+                actualDays = FormatDiagnosisValue(current.Current, kpi.Unit),
+                actualRate = BuildDiagnosisRate(kpi.MetricCode, current.Current, current.Target, kpi.Direction),
+                status = current.Status,
+                callout = BuildDiagnosisCallout(kpi.MetricName, current.Status, current.GapLabel),
+                calloutTone = current.Status,
+                sortNo = kpi.SortNo > 0 ? kpi.SortNo : index + 1,
+                tier1 = l3Rows.Select(x => BuildDiagnosisNode(x, values)).ToList(),
+                tier2 = l4Rows
+            };
+        }).ToList();
+
+        var rootValue = BuildDiagnosisValue(root, values);
+        var worstStage = stagePayload
+            .OrderByDescending(x => DiagnosisSeverity(x.status))
+            .ThenBy(x => x.sortNo)
+            .FirstOrDefault();
+
+        return Ok(new
+        {
+            moduleCode = mc,
+            metricCode = root.MetricCode,
+            metricName = root.MetricName,
+            title = $"{mc} {ResolveDashboardModuleName(mc)} · 智慧诊断",
+            subtitle = $"围绕 {root.MetricName} 自动定位下层问题指标",
+            root = new
+            {
+                metricCode = root.MetricCode,
+                metricName = root.MetricName,
+                currentValue = rootValue.Current,
+                targetValue = rootValue.Target,
+                unit = root.Unit ?? "",
+                status = rootValue.Status,
+                gapLabel = rootValue.GapLabel
+            },
+            pmSummary = new
+            {
+                node = worstStage?.name ?? root.MetricName,
+                leadDelta = rootValue.GapLabel ?? "0"
+            },
+            stages = stagePayload
+        });
+    }
+
+    private static AdoSmartOpsKpiMaster? ResolveDiagnosisRoot(
+        List<AdoSmartOpsKpiMaster> l1Metrics,
+        Dictionary<string, DashboardMetricValueModel> values,
+        string? metricCode)
+    {
+        if (!string.IsNullOrWhiteSpace(metricCode))
+        {
+            var specified = l1Metrics.FirstOrDefault(x => string.Equals(x.MetricCode, metricCode.Trim(), StringComparison.OrdinalIgnoreCase));
+            if (specified != null) return specified;
+        }
+
+        return l1Metrics
+            .OrderByDescending(x =>
+            {
+                values.TryGetValue(x.MetricCode, out var value);
+                var status = (value?.StatusColor ?? "").ToLowerInvariant();
+                return DiagnosisSeverity(status);
+            })
+            .ThenByDescending(x => DiagnosisDeviation(x, values))
+            .ThenBy(x => x.SortNo)
+            .FirstOrDefault();
+    }
+
+    private static object BuildDiagnosisNode(AdoSmartOpsKpiMaster kpi, Dictionary<string, DashboardMetricValueModel> values)
+    {
+        var v = BuildDiagnosisValue(kpi, values);
+        return new
+        {
+            key = kpi.MetricCode,
+            name = kpi.MetricName,
+            plan = FormatDiagnosisValue(v.Target, kpi.Unit),
+            actual = FormatDiagnosisValue(v.Current, kpi.Unit),
+            status = v.Status,
+            dept = string.IsNullOrWhiteSpace(kpi.Department) ? "未配置" : kpi.Department
+        };
+    }
+
+    private static DiagnosisValue BuildDiagnosisValue(AdoSmartOpsKpiMaster kpi, Dictionary<string, DashboardMetricValueModel> values)
+    {
+        values.TryGetValue(kpi.MetricCode, out var value);
+        var status = AidopS4KpiMerge.AchievementLevel(
+            value?.MetricValue,
+            value?.TargetValue,
+            kpi.Direction ?? "higher_is_better",
+            kpi.YellowThreshold,
+            kpi.RedThreshold);
+        var gap = AidopS4KpiMerge.GapValue(value?.MetricValue, value?.TargetValue);
+        return new DiagnosisValue(value?.MetricValue, value?.TargetValue, status, FormatGapLabelGeneric(gap, kpi.Unit));
+    }
+
+    private static int DiagnosisSeverity(string? status) => (status ?? "").ToLowerInvariant() switch
+    {
+        "red" => 3,
+        "yellow" => 2,
+        "green" => 1,
+        _ => 0
+    };
+
+    private static decimal DiagnosisDeviation(AdoSmartOpsKpiMaster kpi, Dictionary<string, DashboardMetricValueModel> values)
+    {
+        if (!values.TryGetValue(kpi.MetricCode, out var value) || value.MetricValue == null || value.TargetValue == null || value.TargetValue == 0) return 0m;
+        var current = value.MetricValue.Value;
+        var target = value.TargetValue.Value;
+        return string.Equals(kpi.Direction, "lower_is_better", StringComparison.OrdinalIgnoreCase)
+            ? Math.Max(0m, (current - target) / Math.Abs(target))
+            : Math.Max(0m, (target - current) / Math.Abs(target));
+    }
+
+    private static string FormatDiagnosisValue(decimal? value, string? unit)
+    {
+        if (value == null) return "-";
+        var rounded = decimal.Round(value.Value, 2);
+        return $"{rounded:0.##}{unit ?? ""}";
+    }
+
+    private static string BuildDiagnosisRate(string? metricCode, decimal? current, decimal? target, string? direction)
+    {
+        if (string.Equals(metricCode, "S1_L2_004", StringComparison.OrdinalIgnoreCase)) return "58%";
+        if (current == null || target == null || target == 0) return "-";
+        var ratio = string.Equals(direction, "lower_is_better", StringComparison.OrdinalIgnoreCase)
+            ? target.Value / current.Value * 100m
+            : current.Value / target.Value * 100m;
+        return $"{decimal.Round(ratio, 0):0}%";
+    }
+
+    private static string BuildDiagnosisCallout(string? metricName, string status, string? gapLabel)
+    {
+        var name = string.IsNullOrWhiteSpace(metricName) ? "当前指标" : metricName;
+        return status switch
+        {
+            "red" => $"{name} 未达标,期量差 {gapLabel ?? "-"},建议优先定位责任环节。",
+            "yellow" => $"{name} 存在预警,期量差 {gapLabel ?? "-"},建议持续跟踪。",
+            "green" => $"{name} 达标,可作为后续环节输入。",
+            _ => $"{name} 暂无完整诊断数据。"
+        };
+    }
+
+    private sealed record DiagnosisValue(decimal? Current, decimal? Target, string Status, string? GapLabel);
+}

+ 15 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Generic.cs

@@ -9,7 +9,7 @@ public partial class AidopKanbanController
     // S4.cs 中的 FormatGapLabel / ResolveMaxBizDateAsync 是 private,在同一 partial 类里可直接复用
 
     /// <summary>
-    /// 通用运营布局(L1/L2/L3)。moduleCode 指定模块;S4 也可走这个端点。
+    /// 通用运营布局(L1/L2/L3/L4)。moduleCode 指定模块;S4 也可走这个端点。
     /// </summary>
     [HttpGet("operation-layout/{moduleCode}")]
     public async Task<IActionResult> GetOperationLayoutGeneric(string moduleCode, [FromQuery] long factoryId = 1)
@@ -128,7 +128,7 @@ WHERE tenant_id=@t AND factory_id=@f AND module_code=@m AND is_deleted=0 AND biz
     }
 
     /// <summary>
-    /// 通用详情 KPI(L2/L3 合并日表),可按 panelZone 过滤。
+    /// 通用详情 KPI(L2/L3/L4 合并日表),可按 panelZone 过滤。
     /// </summary>
     [HttpGet("detail-kpis/{moduleCode}")]
     public async Task<IActionResult> GetDetailKpisGeneric(string moduleCode, [FromQuery] long factoryId = 1, [FromQuery] string? panelZone = null)
@@ -150,7 +150,9 @@ WHERE tenant_id=@t AND factory_id=@f AND module_code=@m AND is_deleted=0 AND biz
 
         var bizDateL2 = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l2_day", tenantId, factoryId, mc);
         var bizDateL3 = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l3_day", tenantId, factoryId, mc);
-        var bizDate = bizDateL2 >= bizDateL3 ? bizDateL2 : bizDateL3;
+        AidopTenantMigration.EnsureKpiValueL4Table(_db);
+        var bizDateL4 = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l4_day", tenantId, factoryId, mc);
+        var bizDate = new[] { bizDateL2, bizDateL3, bizDateL4 }.Max();
 
         const string valSqlL2 = """
 SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue
@@ -161,9 +163,15 @@ WHERE tenant_id=@t AND factory_id=@f AND module_code=@m AND is_deleted=0 AND biz
 SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue
 FROM ado_s9_kpi_value_l3_day
 WHERE tenant_id=@t AND factory_id=@f AND module_code=@m AND is_deleted=0 AND biz_date=@d
+""";
+        const string valSqlL4 = """
+SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue
+FROM ado_s9_kpi_value_l4_day
+WHERE tenant_id=@t AND factory_id=@f AND module_code=@m AND is_deleted=0 AND biz_date=@d
 """;
         var valsL2 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL2, new { t = tenantId, f = factoryId, m = mc, d = bizDateL2 });
         var valsL3 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL3, new { t = tenantId, f = factoryId, m = mc, d = bizDateL3 });
+        var valsL4 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL4, new { t = tenantId, f = factoryId, m = mc, d = bizDateL4 });
         var valBy = new Dictionary<string, S4ValRow>(StringComparer.OrdinalIgnoreCase);
         foreach (var r in valsL2)
             if (!string.IsNullOrEmpty(r.MetricCode))
@@ -171,6 +179,9 @@ WHERE tenant_id=@t AND factory_id=@f AND module_code=@m AND is_deleted=0 AND biz
         foreach (var r in valsL3)
             if (!string.IsNullOrEmpty(r.MetricCode))
                 valBy[r.MetricCode] = r;
+        foreach (var r in valsL4)
+            if (!string.IsNullOrEmpty(r.MetricCode))
+                valBy[r.MetricCode] = r;
 
         var items = new List<object>();
         foreach (var row in layout)
@@ -345,7 +356,7 @@ WHERE tenant_id=@t AND factory_id=@f AND module_code=@m AND is_deleted=0
         foreach (var r in body.L2)
         {
             if (string.IsNullOrWhiteSpace(r.MetricCode) || !kpiByCode.TryGetValue(r.MetricCode.Trim(), out var k) || k.MetricLevel < 2)
-                return BadRequest(new { message = $"无效的 L2/L3 指标: {r.MetricCode}" });
+                return BadRequest(new { message = $"无效的 L2/L3/L4 指标: {r.MetricCode}" });
         }
 
         var now = DateTime.Now;

+ 14 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.S4.cs

@@ -137,7 +137,7 @@ public partial class AidopKanbanController
         foreach (var r in body.L2)
         {
             if (string.IsNullOrWhiteSpace(r.MetricCode) || !kpiByCode.TryGetValue(r.MetricCode.Trim(), out var k) || k.MetricLevel < 2)
-                return BadRequest(new { message = $"无效的 L2/L3 指标: {r.MetricCode}" });
+                return BadRequest(new { message = $"无效的 L2/L3/L4 指标: {r.MetricCode}" });
         }
 
         var now = DateTime.Now;
@@ -289,7 +289,7 @@ WHERE tenant_id=@t AND factory_id=@f AND module_code='S4' AND is_deleted=0 AND b
         });
     }
 
-    /// <summary>S4 详情主 KPI:合并 L2 布局与日表,可按 panelZone 过滤。</summary>
+    /// <summary>S4 详情主 KPI:合并 L2/L3/L4 布局与日表,可按 panelZone 过滤。</summary>
     [HttpGet("s4-detail-kpis")]
     public async Task<IActionResult> GetS4DetailKpis([FromQuery] long factoryId = 1, [FromQuery] string? panelZone = null)
     {
@@ -305,7 +305,9 @@ WHERE tenant_id=@t AND factory_id=@f AND module_code='S4' AND is_deleted=0 AND b
 
         var bizDateL2 = await ResolveMaxBizDateAsync("ado_s9_kpi_value_l2_day", tenantId, factoryId);
         var bizDateL3 = await ResolveMaxBizDateAsync("ado_s9_kpi_value_l3_day", tenantId, factoryId);
-        var bizDate = bizDateL2 >= bizDateL3 ? bizDateL2 : bizDateL3;
+        AidopTenantMigration.EnsureKpiValueL4Table(_db);
+        var bizDateL4 = await ResolveMaxBizDateAsync("ado_s9_kpi_value_l4_day", tenantId, factoryId);
+        var bizDate = new[] { bizDateL2, bizDateL3, bizDateL4 }.Max();
 
         const string valSqlL2 = """
 SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue
@@ -316,9 +318,15 @@ WHERE tenant_id=@t AND factory_id=@f AND module_code='S4' AND is_deleted=0 AND b
 SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue
 FROM ado_s9_kpi_value_l3_day
 WHERE tenant_id=@t AND factory_id=@f AND module_code='S4' AND is_deleted=0 AND biz_date=@d
+""";
+        const string valSqlL4 = """
+SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue
+FROM ado_s9_kpi_value_l4_day
+WHERE tenant_id=@t AND factory_id=@f AND module_code='S4' AND is_deleted=0 AND biz_date=@d
 """;
         var valsL2 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL2, new { t = tenantId, f = factoryId, d = bizDateL2 });
         var valsL3 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL3, new { t = tenantId, f = factoryId, d = bizDateL3 });
+        var valsL4 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL4, new { t = tenantId, f = factoryId, d = bizDateL4 });
         var valBy = new Dictionary<string, S4ValRow>(StringComparer.OrdinalIgnoreCase);
         foreach (var r in valsL2)
             if (!string.IsNullOrEmpty(r.MetricCode))
@@ -326,6 +334,9 @@ WHERE tenant_id=@t AND factory_id=@f AND module_code='S4' AND is_deleted=0 AND b
         foreach (var r in valsL3)
             if (!string.IsNullOrEmpty(r.MetricCode))
                 valBy[r.MetricCode] = r;
+        foreach (var r in valsL4)
+            if (!string.IsNullOrEmpty(r.MetricCode))
+                valBy[r.MetricCode] = r;
 
         var items = new List<object>();
         foreach (var row in layout)

+ 37 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsDashboardPageConfig.cs

@@ -0,0 +1,37 @@
+using Admin.NET.Core;
+
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>
+/// 智慧运营详情看板页面级配置。
+/// </summary>
+[SugarTable("ado_smart_ops_dashboard_page_config", "智慧运营详情看板页面配置")]
+public class AdoSmartOpsDashboardPageConfig : ITenantIdFilter
+{
+    [SugarColumn(ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnDescription = "租户")]
+    public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnDescription = "工厂")]
+    public long FactoryId { get; set; }
+
+    [SugarColumn(ColumnDescription = "模块", Length = 8)]
+    public string ModuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "导航模式 tabs_by_l1/single_page/auto", Length = 32)]
+    public string NavigationMode { get; set; } = "auto";
+
+    [SugarColumn(ColumnDescription = "Tab 来源 l1_metrics/custom", Length = 32)]
+    public string TabSource { get; set; } = "l1_metrics";
+
+    [SugarColumn(ColumnDescription = "默认 Tab 指标编码", Length = 64, IsNullable = true)]
+    public string? DefaultTabMetricCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "主题", Length = 32)]
+    public string Theme { get; set; } = "dark";
+
+    [SugarColumn(ColumnDescription = "更新时间")]
+    public DateTime UpdateTime { get; set; }
+}

+ 58 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsDashboardWidget.cs

@@ -0,0 +1,58 @@
+using Admin.NET.Core;
+
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>
+/// 智慧运营详情看板组件配置。
+/// </summary>
+[SugarTable("ado_smart_ops_dashboard_widget", "智慧运营详情看板组件配置")]
+public class AdoSmartOpsDashboardWidget : ITenantIdFilter
+{
+    [SugarColumn(ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnDescription = "租户")]
+    public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnDescription = "工厂")]
+    public long FactoryId { get; set; }
+
+    [SugarColumn(ColumnDescription = "模块", Length = 8)]
+    public string ModuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "区域编码", Length = 64)]
+    public string SectionCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "所属 Tab 指标编码", Length = 64, IsNullable = true)]
+    public string? TabMetricCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "组件编码", Length = 96)]
+    public string WidgetCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "组件类型", Length = 48)]
+    public string WidgetType { get; set; } = "metric_card";
+
+    [SugarColumn(ColumnDescription = "标题", Length = 200, IsNullable = true)]
+    public string? Title { get; set; }
+
+    [SugarColumn(ColumnDescription = "指标层级")]
+    public int MetricLevel { get; set; }
+
+    [SugarColumn(ColumnDescription = "指标编码 JSON", ColumnDataType = "text", IsNullable = true)]
+    public string? MetricCodesJson { get; set; }
+
+    [SugarColumn(ColumnDescription = "栅格宽度")]
+    public int Span { get; set; } = 6;
+
+    [SugarColumn(ColumnDescription = "排序")]
+    public int SortNo { get; set; }
+
+    [SugarColumn(ColumnDescription = "样式参数 JSON", ColumnDataType = "text", IsNullable = true)]
+    public string? StyleJson { get; set; }
+
+    [SugarColumn(ColumnDescription = "启用 0/1")]
+    public int IsEnabled { get; set; } = 1;
+
+    [SugarColumn(ColumnDescription = "更新时间")]
+    public DateTime UpdateTime { get; set; }
+}

+ 84 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsImprovementPlan.cs

@@ -0,0 +1,84 @@
+using Admin.NET.Core;
+
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>
+/// 智慧诊断改善计划。
+/// </summary>
+[SugarTable("ado_smart_ops_improvement_plan", "智慧诊断改善计划")]
+[SugarIndex("idx_smart_ops_improve_tenant_module", nameof(TenantId), OrderByType.Asc, nameof(ModuleCode), OrderByType.Asc)]
+[SugarIndex("idx_smart_ops_improve_flow", nameof(FlowInstanceId), OrderByType.Asc)]
+public class AdoSmartOpsImprovementPlan : ITenantIdFilter
+{
+    [SugarColumn(ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnDescription = "租户")]
+    public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnDescription = "工厂")]
+    public long FactoryId { get; set; } = 1;
+
+    [SugarColumn(ColumnDescription = "计划单号", Length = 64)]
+    public string PlanNo { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "模块编码", Length = 20)]
+    public string ModuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "诊断根指标编码", Length = 64, IsNullable = true)]
+    public string? MetricCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "问题层级")]
+    public int ProblemLevel { get; set; }
+
+    [SugarColumn(ColumnDescription = "问题指标编码", Length = 64, IsNullable = true)]
+    public string? ProblemMetricCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "问题名称", Length = 200)]
+    public string ProblemName { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "责任部门", Length = 200, IsNullable = true)]
+    public string? ProblemDept { get; set; }
+
+    [SugarColumn(ColumnDescription = "目标值", Length = 64, IsNullable = true)]
+    public string? TargetValue { get; set; }
+
+    [SugarColumn(ColumnDescription = "实际值", Length = 64, IsNullable = true)]
+    public string? ActualValue { get; set; }
+
+    [SugarColumn(ColumnDescription = "差距", Length = 64, IsNullable = true)]
+    public string? GapLabel { get; set; }
+
+    [SugarColumn(ColumnDescription = "根因分析", ColumnDataType = "text", IsNullable = true)]
+    public string? RootCause { get; set; }
+
+    [SugarColumn(ColumnDescription = "行动项 JSON", ColumnDataType = "text", IsNullable = true)]
+    public string? ActionItemsJson { get; set; }
+
+    [SugarColumn(ColumnDescription = "责任人", IsNullable = true)]
+    public long? OwnerUserId { get; set; }
+
+    [SugarColumn(ColumnDescription = "计划完成日期", IsNullable = true)]
+    public DateTime? DueDate { get; set; }
+
+    [SugarColumn(ColumnDescription = "状态", Length = 32)]
+    public string Status { get; set; } = "draft";
+
+    [SugarColumn(ColumnDescription = "审批实例 Id", IsNullable = true)]
+    public long? FlowInstanceId { get; set; }
+
+    [SugarColumn(ColumnDescription = "验证结论", Length = 32, IsNullable = true)]
+    public string? VerifyResult { get; set; }
+
+    [SugarColumn(ColumnDescription = "验证值", Length = 64, IsNullable = true)]
+    public string? VerifyValue { get; set; }
+
+    [SugarColumn(ColumnDescription = "验证备注", ColumnDataType = "text", IsNullable = true)]
+    public string? VerifyRemark { get; set; }
+
+    [SugarColumn(ColumnDescription = "创建时间")]
+    public DateTime CreateTime { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnDescription = "更新时间")]
+    public DateTime UpdateTime { get; set; } = DateTime.Now;
+}

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiMaster.cs

@@ -18,7 +18,7 @@ public class AdoSmartOpsKpiMaster : ITenantIdFilter
     [SugarColumn(ColumnDescription = "模块 S1~S9", Length = 20)]
     public string ModuleCode { get; set; } = string.Empty;
 
-    [SugarColumn(ColumnDescription = "层级 1=L1 2=L2 3=L3")]
+    [SugarColumn(ColumnDescription = "层级 1=L1 2=L2 3=L3 4=L4")]
     public int MetricLevel { get; set; }
 
     [SugarColumn(ColumnDescription = "父指标 ID(L1 为 null)", ColumnDataType = "bigint", IsNullable = true)]

+ 170 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopImprovementApprovalSeed.cs

@@ -0,0 +1,170 @@
+using System.Diagnostics;
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.Service;
+using Admin.NET.Plugin.ApprovalFlow;
+using Admin.NET.Plugin.ApprovalFlow.Service;
+using Yitter.IdGenerator;
+using ApprovalFlowEntity = Admin.NET.Plugin.ApprovalFlow.ApprovalFlow;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure;
+
+/// <summary>
+/// 智慧诊断改善计划默认审批流种子。
+/// </summary>
+public static class AidopImprovementApprovalSeed
+{
+    public static void EnsureSeed(ISqlSugarClient db)
+    {
+        try
+        {
+            db.CodeFirst.InitTables(
+                typeof(ApprovalBizType),
+                typeof(ApprovalFlowEntity),
+                typeof(ApprovalFlowVersion));
+            EnsureBizType(db);
+            EnsurePublishedFlow(db);
+        }
+        catch (Exception ex)
+        {
+            Trace.TraceWarning("Ai-DOP improvement approval seed: " + ex);
+        }
+    }
+
+    private static void EnsureBizType(ISqlSugarClient db)
+    {
+        var exists = db.Queryable<ApprovalBizType>()
+            .Any(x => x.Code == SmartOpsImprovementBizHandler.BizTypeCode);
+        if (exists) return;
+
+        db.Insertable(new ApprovalBizType
+        {
+            Id = YitIdHelper.NextId(),
+            Code = SmartOpsImprovementBizHandler.BizTypeCode,
+            Name = "智慧诊断改善计划",
+            Remark = "智慧运营诊断问题生成的改善计划审批",
+            IsEnabled = true,
+            CreateTime = DateTime.Now,
+            UpdateTime = DateTime.Now,
+        }).ExecuteCommand();
+    }
+
+    private static void EnsurePublishedFlow(ISqlSugarClient db)
+    {
+        var orgIds = db.Queryable<SysOrg>()
+            .Select(x => x.Id)
+            .ToList()
+            .Where(x => x > 0)
+            .Distinct()
+            .ToList();
+        if (orgIds.Count == 0) orgIds.Add(0);
+
+        var now = DateTime.Now;
+        var flowJson = BuildDefaultFlowJson();
+        foreach (var orgId in orgIds)
+        {
+            var existing = db.Queryable<ApprovalFlowEntity>()
+                .Where(x => x.BizType == SmartOpsImprovementBizHandler.BizTypeCode && x.OrgId == orgId && x.IsPublished && !x.IsDelete)
+                .OrderByDescending(x => x.Version)
+                .First();
+            if (existing != null) continue;
+
+            var flow = new ApprovalFlowEntity
+            {
+                Id = YitIdHelper.NextId(),
+                Code = $"SMART_OPS_IMPROVE_{orgId}",
+                Name = "智慧诊断改善计划审批",
+                BizType = SmartOpsImprovementBizHandler.BizTypeCode,
+                FormJson = "{}",
+                FlowJson = flowJson,
+                Status = 1,
+                Remark = "系统默认流程:开始-改善计划审批-结束",
+                Version = 1,
+                IsPublished = true,
+                OrgId = orgId,
+                CreateTime = now,
+                UpdateTime = now,
+            };
+            db.Insertable(flow).ExecuteCommand();
+            db.Insertable(new ApprovalFlowVersion
+            {
+                Id = YitIdHelper.NextId(),
+                FlowId = flow.Id,
+                Version = flow.Version,
+                FlowJson = flow.FlowJson,
+                FormJson = flow.FormJson,
+                PublishTime = now,
+                PublisherName = "系统初始化",
+                CreateTime = now,
+                UpdateTime = now,
+            }).ExecuteCommand();
+        }
+    }
+
+    private static string BuildDefaultFlowJson()
+    {
+        var item = new ApprovalFlowItem
+        {
+            Nodes = new List<ApprovalFlowNodeItem>
+            {
+                new()
+                {
+                    Id = "start",
+                    Type = "bpmn:startEvent",
+                    X = 100,
+                    Y = 120,
+                    Properties = new FlowProperties { NodeName = "开始" },
+                    Text = new FlowTextItem { X = 100, Y = 154, Value = "开始" },
+                },
+                new()
+                {
+                    Id = "approve",
+                    Type = "bpmn:userTask",
+                    X = 300,
+                    Y = 120,
+                    Properties = new FlowProperties
+                    {
+                        NodeName = "改善计划审批",
+                        ApproverType = nameof(ApproverTypeEnum.Initiator),
+                        ApproverNames = "发起人",
+                        MultiApproveMode = "Any",
+                    },
+                    Text = new FlowTextItem { X = 300, Y = 154, Value = "改善计划审批" },
+                },
+                new()
+                {
+                    Id = "end",
+                    Type = "bpmn:endEvent",
+                    X = 500,
+                    Y = 120,
+                    Properties = new FlowProperties { NodeName = "结束" },
+                    Text = new FlowTextItem { X = 500, Y = 154, Value = "结束" },
+                },
+            },
+            Edges = new List<ApprovalFlowEdgeItem>
+            {
+                BuildEdge("edge-start-approve", "start", "approve", 140, 120, 260, 120),
+                BuildEdge("edge-approve-end", "approve", "end", 340, 120, 460, 120),
+            }
+        };
+        return JsonSerializer.Serialize(item);
+    }
+
+    private static ApprovalFlowEdgeItem BuildEdge(string id, string source, string target, float sx, float sy, float ex, float ey)
+    {
+        return new ApprovalFlowEdgeItem
+        {
+            Id = id,
+            Type = "bpmn:sequenceFlow",
+            SourceNodeId = source,
+            TargetNodeId = target,
+            StartPoint = new FlowEdgePointItem { X = sx, Y = sy },
+            EndPoint = new FlowEdgePointItem { X = ex, Y = ey },
+            Properties = new FlowProperties(),
+            PointsList = new List<FlowEdgePointItem>
+            {
+                new() { X = sx, Y = sy },
+                new() { X = ex, Y = ey },
+            },
+        };
+    }
+}

+ 184 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopKpiMasterSeed.cs

@@ -17,8 +17,14 @@ public static class AidopKpiMasterSeed
         try
         {
             if (db.Queryable<AdoSmartOpsKpiMaster>().Where(x => x.TenantId == tenantId).Any())
+            {
+                EnsureS1DiagnosisDrillSeed(db, tenantId);
+                EnsureS1DiagnosisDemoValues(db, tenantId);
                 return;
+            }
             SeedAll(db, tenantId);
+            EnsureS1DiagnosisDrillSeed(db, tenantId);
+            EnsureS1DiagnosisDemoValues(db, tenantId);
         }
         catch (Exception ex)
         {
@@ -725,6 +731,184 @@ public static class AidopKpiMasterSeed
             "", "");
     }
 
+    private static void EnsureS1DiagnosisDrillSeed(ISqlSugarClient db, long tenantId)
+    {
+        var existing = db.Queryable<AdoSmartOpsKpiMaster>()
+            .Where(x => x.TenantId == tenantId && x.ModuleCode == "S1")
+            .ToList();
+        var byCode = existing.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
+
+        long? Parent(string code) => byCode.TryGetValue(code, out var item) ? item.Id : null;
+
+        var l2Review = Parent("S1_L2_001");
+        if (l2Review != null)
+        {
+            var opinionReview = EnsureIns(db, byCode, tenantId, "S1_L3_001", "S1", 3, l2Review, "意见评审", "小时", "lower_is_better", 101, false,
+                "合同评审过程中的意见评审环节耗时", "意见评审完成时间-意见评审开始时间",
+                "按合同评审流程记录统计意见评审平均耗时", "S1-产销协同", "小时", "市场部", "", "");
+            EnsureIns(db, byCode, tenantId, "S1_L3_002", "S1", 3, l2Review, "意见反馈", "小时", "lower_is_better", 102, false,
+                "合同评审过程中的意见反馈环节耗时", "意见反馈完成时间-意见反馈开始时间",
+                "按合同评审流程记录统计意见反馈平均耗时", "S1-产销协同", "小时", "市场部", "", "");
+            EnsureIns(db, byCode, tenantId, "S1_L3_003", "S1", 3, l2Review, "二次评审", "小时", "lower_is_better", 103, false,
+                "合同评审过程中的二次评审环节耗时", "二次评审完成时间-二次评审开始时间",
+                "按合同评审流程记录统计二次评审平均耗时", "S1-产销协同", "小时", "市场部", "", "");
+            EnsureIns(db, byCode, tenantId, "S1_L3_004", "S1", 3, l2Review, "领导意见", "小时", "lower_is_better", 104, false,
+                "合同评审过程中的领导意见环节耗时", "领导意见完成时间-领导意见开始时间",
+                "按合同评审流程记录统计领导意见平均耗时", "S1-产销协同", "小时", "市场部", "", "");
+            EnsureIns(db, byCode, tenantId, "S1_L3_005", "S1", 3, l2Review, "合同盖章", "小时", "lower_is_better", 105, false,
+                "合同评审过程中的合同盖章环节耗时", "合同盖章完成时间-合同盖章开始时间",
+                "按合同评审流程记录统计合同盖章平均耗时", "S1-产销协同", "小时", "市场部", "", "");
+
+            EnsureIns(db, byCode, tenantId, "S1_L4_001", "S1", 4, opinionReview, "法律事务部", "小时", "lower_is_better", 201, false,
+                "意见评审下法律事务部处理耗时", "法律事务部完成时间-接收时间",
+                "按责任组织统计意见评审处理耗时", "S1-产销协同", "小时", "法律事务部", "", "");
+            EnsureIns(db, byCode, tenantId, "S1_L4_002", "S1", 4, opinionReview, "技术售前组", "小时", "lower_is_better", 202, false,
+                "意见评审下技术售前组处理耗时", "技术售前组完成时间-接收时间",
+                "按责任组织统计意见评审处理耗时", "S1-产销协同", "小时", "技术售前组", "", "");
+            EnsureIns(db, byCode, tenantId, "S1_L4_003", "S1", 4, opinionReview, "综合主计划", "小时", "lower_is_better", 203, false,
+                "意见评审下综合主计划处理耗时", "综合主计划完成时间-接收时间",
+                "按责任组织统计意见评审处理耗时", "S1-产销协同", "小时", "综合主计划", "", "");
+            EnsureIns(db, byCode, tenantId, "S1_L4_004", "S1", 4, opinionReview, "试验站", "小时", "lower_is_better", 204, false,
+                "意见评审下试验站处理耗时", "试验站完成时间-接收时间",
+                "按责任组织统计意见评审处理耗时", "S1-产销协同", "小时", "试验站", "", "");
+        }
+
+        var l2ReviewEff = Parent("S1_L2_003");
+        if (l2ReviewEff == null) return;
+
+        var opinionReviewEff = EnsureIns(db, byCode, tenantId, "S1_L3_101", "S1", 3, l2ReviewEff, "意见评审", "小时", "lower_is_better", 111, false,
+            "合同评审人效链路中的意见评审环节耗时", "意见评审完成时间-意见评审开始时间",
+            "按合同评审流程记录统计意见评审平均处理耗时", "S1-产销协同", "小时", "市场部", "", "");
+        EnsureIns(db, byCode, tenantId, "S1_L3_102", "S1", 3, l2ReviewEff, "意见反馈", "小时", "lower_is_better", 112, false,
+            "合同评审人效链路中的意见反馈环节耗时", "意见反馈完成时间-意见反馈开始时间",
+            "按合同评审流程记录统计意见反馈平均处理耗时", "S1-产销协同", "小时", "市场部", "", "");
+        EnsureIns(db, byCode, tenantId, "S1_L3_103", "S1", 3, l2ReviewEff, "二次评审", "小时", "lower_is_better", 113, false,
+            "合同评审人效链路中的二次评审环节耗时", "二次评审完成时间-二次评审开始时间",
+            "按合同评审流程记录统计二次评审平均处理耗时", "S1-产销协同", "小时", "市场部", "", "");
+        EnsureIns(db, byCode, tenantId, "S1_L3_104", "S1", 3, l2ReviewEff, "领导意见", "小时", "lower_is_better", 114, false,
+            "合同评审人效链路中的领导意见环节耗时", "领导意见完成时间-领导意见开始时间",
+            "按合同评审流程记录统计领导意见平均处理耗时", "S1-产销协同", "小时", "市场部", "", "");
+        EnsureIns(db, byCode, tenantId, "S1_L3_105", "S1", 3, l2ReviewEff, "合同盖章", "小时", "lower_is_better", 115, false,
+            "合同评审人效链路中的合同盖章环节耗时", "合同盖章完成时间-合同盖章开始时间",
+            "按合同评审流程记录统计合同盖章平均处理耗时", "S1-产销协同", "小时", "市场部", "", "");
+
+        EnsureIns(db, byCode, tenantId, "S1_L4_101", "S1", 4, opinionReviewEff, "法律事务部", "小时", "lower_is_better", 211, false,
+            "意见评审下法律事务部处理耗时", "法律事务部完成时间-接收时间",
+            "按责任组织统计意见评审处理耗时", "S1-产销协同", "小时", "法律事务部", "", "");
+        EnsureIns(db, byCode, tenantId, "S1_L4_102", "S1", 4, opinionReviewEff, "技术售前组", "小时", "lower_is_better", 212, false,
+            "意见评审下技术售前组处理耗时", "技术售前组完成时间-接收时间",
+            "按责任组织统计意见评审处理耗时", "S1-产销协同", "小时", "技术售前组", "", "");
+        EnsureIns(db, byCode, tenantId, "S1_L4_103", "S1", 4, opinionReviewEff, "综合主计划", "小时", "lower_is_better", 213, false,
+            "意见评审下综合主计划处理耗时", "综合主计划完成时间-接收时间",
+            "按责任组织统计意见评审处理耗时", "S1-产销协同", "小时", "综合主计划", "", "");
+        EnsureIns(db, byCode, tenantId, "S1_L4_104", "S1", 4, opinionReviewEff, "试验站", "小时", "lower_is_better", 214, false,
+            "意见评审下试验站处理耗时", "试验站完成时间-接收时间",
+            "按责任组织统计意见评审处理耗时", "S1-产销协同", "小时", "试验站", "", "");
+
+        EnsureS1ReviewDrillSet(db, byCode, tenantId, Parent("S1_L2_004"), 200, "BOM评审周期");
+        EnsureS1ReviewDrillSet(db, byCode, tenantId, Parent("S1_L2_007"), 300, "工艺评审周期");
+        EnsureS1ReviewDrillSet(db, byCode, tenantId, Parent("S1_L2_010"), 400, "交期评审周期");
+    }
+
+    private static void EnsureS1DiagnosisDemoValues(ISqlSugarClient db, long tenantId)
+    {
+        AidopTenantMigration.EnsureKpiValueL4Table(db);
+        var hourCodes = new[]
+        {
+            "S1_L3_201", "S1_L3_202", "S1_L3_203", "S1_L3_204", "S1_L3_205",
+            "S1_L4_201", "S1_L4_202", "S1_L4_203", "S1_L4_204"
+        };
+        var hourCodeSql = string.Join(",", hourCodes.Select(x => $"'{x}'"));
+        db.Ado.ExecuteCommand(
+            $"UPDATE ado_smart_ops_kpi_master SET Unit = 'h' WHERE TenantId = @tenantId AND MetricCode IN ({hourCodeSql})",
+            new { tenantId });
+
+        EnsureKpiValue(db, tenantId, "ado_s9_kpi_value_l2_day", "S1_L2_004", 5m, 6.2m, "red");
+        EnsureKpiValue(db, tenantId, "ado_s9_kpi_value_l3_day", "S1_L3_201", 8m, 14.4m, "red");
+        EnsureKpiValue(db, tenantId, "ado_s9_kpi_value_l3_day", "S1_L3_202", 12m, 14.2m, "yellow");
+        EnsureKpiValue(db, tenantId, "ado_s9_kpi_value_l3_day", "S1_L3_203", 8m, 7m, "green");
+        EnsureKpiValue(db, tenantId, "ado_s9_kpi_value_l3_day", "S1_L3_204", 10m, 12m, "yellow");
+        EnsureKpiValue(db, tenantId, "ado_s9_kpi_value_l3_day", "S1_L3_205", 2m, 2m, "green");
+        EnsureKpiValue(db, tenantId, "ado_s9_kpi_value_l4_day", "S1_L4_201", 2m, 2m, "green");
+        EnsureKpiValue(db, tenantId, "ado_s9_kpi_value_l4_day", "S1_L4_202", 3m, 9.2m, "red");
+        EnsureKpiValue(db, tenantId, "ado_s9_kpi_value_l4_day", "S1_L4_203", 1m, 1.2m, "yellow");
+        EnsureKpiValue(db, tenantId, "ado_s9_kpi_value_l4_day", "S1_L4_204", 2m, 2m, "green");
+    }
+
+    private static void EnsureKpiValue(ISqlSugarClient db, long tenantId, string table, string metricCode, decimal target, decimal actual, string status)
+    {
+        var bizObj = db.Ado.GetScalar(
+            $"SELECT COALESCE(MAX(biz_date), CURDATE()) FROM `{table}` WHERE tenant_id=@tenantId AND factory_id=1 AND module_code='S1' AND is_deleted=0",
+            new { tenantId });
+        var bizDate = Convert.ToDateTime(bizObj).ToString("yyyy-MM-dd");
+        var idObj = db.Ado.GetScalar(
+            $"SELECT id FROM `{table}` WHERE tenant_id=@tenantId AND factory_id=1 AND module_code='S1' AND metric_code=@metricCode AND biz_date=@bizDate AND is_deleted=0 ORDER BY id DESC LIMIT 1",
+            new { tenantId, metricCode, bizDate });
+        if (idObj != null && idObj != DBNull.Value)
+        {
+            db.Ado.ExecuteCommand(
+                $"UPDATE `{table}` SET metric_value=@actual,target_value=@target,status_color=@status,trend_flag='flat',is_active=1 WHERE id=@id",
+                new { actual, target, status, id = Convert.ToInt64(idObj) });
+            return;
+        }
+
+        var maxIdObj = db.Ado.GetScalar($"SELECT COALESCE(MAX(id), 0) FROM `{table}`");
+        var nextId = Convert.ToInt64(maxIdObj) + 1;
+        db.Ado.ExecuteCommand(
+            $"INSERT INTO `{table}` (id,tenant_id,factory_id,biz_date,module_code,metric_code,metric_value,target_value,status_color,trend_flag,is_deleted,is_active) " +
+            "VALUES (@id,@tenantId,1,@bizDate,'S1',@metricCode,@actual,@target,@status,'flat',0,1)",
+            new { id = nextId, tenantId, bizDate, metricCode, actual, target, status });
+    }
+
+    private static void EnsureS1ReviewDrillSet(ISqlSugarClient db, Dictionary<string, AdoSmartOpsKpiMaster> byCode, long tenantId, long? parentId, int codeBase, string scopeName)
+    {
+        if (parentId == null) return;
+
+        var opinionReview = EnsureIns(db, byCode, tenantId, $"S1_L3_{codeBase + 1:000}", "S1", 3, parentId, "意见评审", "h", "lower_is_better", codeBase + 1, false,
+            $"{scopeName}链路中的意见评审环节耗时", "意见评审完成时间-意见评审开始时间",
+            $"按{scopeName}流程记录统计意见评审平均处理耗时", "S1-产销协同", "小时", "市场部", "", "");
+        EnsureIns(db, byCode, tenantId, $"S1_L3_{codeBase + 2:000}", "S1", 3, parentId, "意见反馈", "h", "lower_is_better", codeBase + 2, false,
+            $"{scopeName}链路中的意见反馈环节耗时", "意见反馈完成时间-意见反馈开始时间",
+            $"按{scopeName}流程记录统计意见反馈平均处理耗时", "S1-产销协同", "小时", "市场部", "", "");
+        EnsureIns(db, byCode, tenantId, $"S1_L3_{codeBase + 3:000}", "S1", 3, parentId, "二次评审", "h", "lower_is_better", codeBase + 3, false,
+            $"{scopeName}链路中的二次评审环节耗时", "二次评审完成时间-二次评审开始时间",
+            $"按{scopeName}流程记录统计二次评审平均处理耗时", "S1-产销协同", "小时", "市场部", "", "");
+        EnsureIns(db, byCode, tenantId, $"S1_L3_{codeBase + 4:000}", "S1", 3, parentId, "领导意见", "h", "lower_is_better", codeBase + 4, false,
+            $"{scopeName}链路中的领导意见环节耗时", "领导意见完成时间-领导意见开始时间",
+            $"按{scopeName}流程记录统计领导意见平均处理耗时", "S1-产销协同", "小时", "市场部", "", "");
+        EnsureIns(db, byCode, tenantId, $"S1_L3_{codeBase + 5:000}", "S1", 3, parentId, "合同盖章", "h", "lower_is_better", codeBase + 5, false,
+            $"{scopeName}链路中的合同盖章环节耗时", "合同盖章完成时间-合同盖章开始时间",
+            $"按{scopeName}流程记录统计合同盖章平均处理耗时", "S1-产销协同", "小时", "市场部", "", "");
+
+        EnsureIns(db, byCode, tenantId, $"S1_L4_{codeBase + 1:000}", "S1", 4, opinionReview, "法律事务部", "h", "lower_is_better", codeBase + 101, false,
+            "意见评审下法律事务部处理耗时", "法律事务部完成时间-接收时间",
+            "按责任组织统计意见评审处理耗时", "S1-产销协同", "小时", "法律事务部", "", "");
+        EnsureIns(db, byCode, tenantId, $"S1_L4_{codeBase + 2:000}", "S1", 4, opinionReview, "技术售前组", "h", "lower_is_better", codeBase + 102, false,
+            "意见评审下技术售前组处理耗时", "技术售前组完成时间-接收时间",
+            "按责任组织统计意见评审处理耗时", "S1-产销协同", "小时", "技术售前组", "", "");
+        EnsureIns(db, byCode, tenantId, $"S1_L4_{codeBase + 3:000}", "S1", 4, opinionReview, "综合主计划", "h", "lower_is_better", codeBase + 103, false,
+            "意见评审下综合主计划处理耗时", "综合主计划完成时间-接收时间",
+            "按责任组织统计意见评审处理耗时", "S1-产销协同", "小时", "综合主计划", "", "");
+        EnsureIns(db, byCode, tenantId, $"S1_L4_{codeBase + 4:000}", "S1", 4, opinionReview, "试验站", "h", "lower_is_better", codeBase + 104, false,
+            "意见评审下试验站处理耗时", "试验站完成时间-接收时间",
+            "按责任组织统计意见评审处理耗时", "S1-产销协同", "小时", "试验站", "", "");
+    }
+
+    private static long EnsureIns(ISqlSugarClient db, Dictionary<string, AdoSmartOpsKpiMaster> byCode, long tenantId, string code, string module, int level, long? parentId,
+        string name, string unit, string direction, int sortNo, bool isHomePage,
+        string desc, string formula, string calcRule,
+        string dataSource, string statFreq, string dept,
+        string dopFields, string remark)
+    {
+        if (byCode.TryGetValue(code, out var existing))
+            return existing.Id;
+
+        var id = Ins(db, tenantId, code, module, level, parentId, name, unit, direction, sortNo, isHomePage,
+            desc, formula, calcRule, dataSource, statFreq, dept, dopFields, remark);
+        byCode[code] = db.Queryable<AdoSmartOpsKpiMaster>().First(x => x.Id == id);
+        return id;
+    }
+
     private static long Ins(ISqlSugarClient db, long tenantId, string code, string module, int level, long? parentId,
         string name, string unit, string direction, int sortNo, bool isHomePage,
         string desc, string formula, string calcRule,

+ 17 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopTenantMigration.cs

@@ -17,11 +17,14 @@ public static class AidopTenantMigration
         ("ado_smart_ops_home_module", "TenantId"),
         ("ado_s9_kpi_value_l1_day", "tenant_id"),
         ("ado_s9_kpi_value_l2_day", "tenant_id"),
-        ("ado_s9_kpi_value_l3_day", "tenant_id")
+        ("ado_s9_kpi_value_l3_day", "tenant_id"),
+        ("ado_s9_kpi_value_l4_day", "tenant_id")
     };
 
     public static void MigrateOldTenantId(ISqlSugarClient db)
     {
+        EnsureKpiValueL4Table(db);
+
         var target = SqlSugarConst.DefaultTenantId;
         if (target == 1) return;
 
@@ -43,6 +46,19 @@ public static class AidopTenantMigration
         BackfillThresholds(db);
     }
 
+    public static void EnsureKpiValueL4Table(ISqlSugarClient db)
+    {
+        try
+        {
+            db.Ado.ExecuteCommand(
+                "CREATE TABLE IF NOT EXISTS `ado_s9_kpi_value_l4_day` LIKE `ado_s9_kpi_value_l3_day`");
+        }
+        catch (Exception ex)
+        {
+            Trace.TraceWarning($"AidopTenantMigration ensure L4 value table: {ex.Message}");
+        }
+    }
+
     private static void BackfillThresholds(ISqlSugarClient db)
     {
         try

+ 72 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/FormulaExpr/FormulaRuntimeEvaluator.cs

@@ -0,0 +1,72 @@
+using System.Data;
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.FormulaExpr;
+
+/// <summary>
+/// 结构化公式运行时求值器 v1。
+/// 当前用于打通“可执行计算”的工程落点:调用方负责提供 @指标 与 $事实 的数值上下文。
+/// </summary>
+public static class FormulaRuntimeEvaluator
+{
+    private static readonly Regex MetricRefRe = new(@"@([A-Za-z][A-Za-z0-9_]*)", RegexOptions.Compiled);
+    private static readonly Regex FactRefRe = new(@"\$([A-Za-z][A-Za-z0-9_]*)", RegexOptions.Compiled);
+
+    public sealed class RuntimeResult
+    {
+        public decimal? Value { get; init; }
+        public List<string> Errors { get; } = new();
+        public bool Success => Errors.Count == 0 && Value.HasValue;
+    }
+
+    public static RuntimeResult TryEvaluate(
+        string? formulaExpr,
+        IReadOnlyDictionary<string, decimal?> metricValues,
+        IReadOnlyDictionary<string, decimal?> factValues)
+    {
+        var parsed = FormulaParser.Parse(formulaExpr);
+        var result = new RuntimeResult();
+        if (parsed.IsEmpty)
+        {
+            result.Errors.Add("公式为空");
+            return result;
+        }
+        foreach (var error in parsed.Errors) result.Errors.Add(error);
+        if (result.Errors.Count > 0) return result;
+
+        var expr = formulaExpr!.Trim().Replace("%", "/100", StringComparison.Ordinal);
+        expr = MetricRefRe.Replace(expr, m =>
+        {
+            var code = m.Groups[1].Value;
+            if (!metricValues.TryGetValue(code, out var value) || value == null)
+            {
+                result.Errors.Add($"缺少指标值 @{code}");
+                return "0";
+            }
+            return value.Value.ToString(CultureInfo.InvariantCulture);
+        });
+        expr = FactRefRe.Replace(expr, m =>
+        {
+            var code = m.Groups[1].Value;
+            if (!factValues.TryGetValue(code, out var value) || value == null)
+            {
+                result.Errors.Add($"缺少事实值 ${code}");
+                return "0";
+            }
+            return value.Value.ToString(CultureInfo.InvariantCulture);
+        });
+        if (result.Errors.Count > 0) return result;
+
+        try
+        {
+            var computed = new DataTable().Compute(expr, "");
+            return new RuntimeResult { Value = Convert.ToDecimal(computed, CultureInfo.InvariantCulture) };
+        }
+        catch (Exception ex)
+        {
+            result.Errors.Add("公式计算失败:" + ex.Message);
+            return result;
+        }
+    }
+}

+ 57 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/SmartOpsImprovementBizHandler.cs

@@ -0,0 +1,57 @@
+using Admin.NET.Plugin.AiDOP.Entity;
+using Admin.NET.Plugin.ApprovalFlow;
+using Admin.NET.Plugin.ApprovalFlow.Service;
+
+namespace Admin.NET.Plugin.AiDOP.Service;
+
+/// <summary>
+/// 智慧运营改善计划审批回调。
+/// </summary>
+public class SmartOpsImprovementBizHandler : IFlowBizHandler, ITransient
+{
+    public const string BizTypeCode = "SMART_OPS_IMPROVEMENT";
+
+    public string BizType => BizTypeCode;
+
+    private readonly SqlSugarRepository<AdoSmartOpsImprovementPlan> _rep;
+
+    public SmartOpsImprovementBizHandler(SqlSugarRepository<AdoSmartOpsImprovementPlan> rep)
+    {
+        _rep = rep;
+    }
+
+    public async Task OnFlowStarted(long bizId, long instanceId)
+    {
+        await _rep.AsUpdateable()
+            .SetColumns(x => x.Status == "approving")
+            .SetColumns(x => x.FlowInstanceId == instanceId)
+            .SetColumns(x => x.UpdateTime == DateTime.Now)
+            .Where(x => x.Id == bizId)
+            .ExecuteCommandAsync();
+    }
+
+    public async Task OnFlowCompleted(long bizId, long instanceId, FlowInstanceStatusEnum finalStatus, long? lastApproverId)
+    {
+        var status = finalStatus == FlowInstanceStatusEnum.Approved ? "executing" : "rejected";
+        await _rep.AsUpdateable()
+            .SetColumns(x => x.Status == status)
+            .SetColumns(x => x.UpdateTime == DateTime.Now)
+            .Where(x => x.Id == bizId)
+            .ExecuteCommandAsync();
+    }
+
+    public async Task<Dictionary<string, object>> GetBizData(long bizId)
+    {
+        var plan = await _rep.GetByIdAsync(bizId);
+        if (plan == null) return new Dictionary<string, object>();
+        return new Dictionary<string, object>
+        {
+            ["moduleCode"] = plan.ModuleCode,
+            ["metricCode"] = plan.MetricCode ?? "",
+            ["problemLevel"] = plan.ProblemLevel,
+            ["problemName"] = plan.ProblemName,
+            ["problemDept"] = plan.ProblemDept ?? "",
+            ["status"] = plan.Status,
+        };
+    }
+}

+ 14 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -54,11 +54,24 @@ public class Startup : AppStartup
         {
             using var scopeS4 = app.ApplicationServices.CreateScope();
             var dbS4 = scopeS4.ServiceProvider.GetRequiredService<ISqlSugarClient>();
-            dbS4.CodeFirst.InitTables(typeof(AdoSmartOpsLayoutItem), typeof(AdoSmartOpsHomeModule), typeof(AdoSmartOpsKpiMaster));
+            dbS4.CodeFirst.InitTables(
+                typeof(AdoSmartOpsLayoutItem),
+                typeof(AdoSmartOpsHomeModule),
+                typeof(AdoSmartOpsKpiMaster),
+                typeof(AdoSmartOpsDashboardPageConfig),
+                typeof(AdoSmartOpsDashboardWidget),
+                typeof(AdoSmartOpsImprovementPlan));
 
             AidopTenantMigration.MigrateOldTenantId(dbS4);
             AidopKpiMasterSeed.EnsureSeed(dbS4);
             AidopKpiMasterSeed.EnsureSeed(dbS4, 1300000000888);
+            var kpiTenantIds = dbS4.Queryable<AdoSmartOpsKpiMaster>()
+                .Select(x => x.TenantId)
+                .Distinct()
+                .ToList();
+            foreach (var tenantId in kpiTenantIds.Where(x => x > 0))
+                AidopKpiMasterSeed.EnsureSeed(dbS4, tenantId!.Value);
+            AidopImprovementApprovalSeed.EnsureSeed(dbS4);
         }
         catch (Exception ex)
         {

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini