Просмотр исходного кода

feat(s8): unified domain-type-metrics endpoint with 24h/7d window toggle

- 新增 GET /api/aidop/s8/monitoring/domain-type-metrics?domain=&window=
- 单一时间窗口下 total / open / closed / avgProcessHours / closeRate 共用同一分母
- 替换三专题页右侧异常类型卡:从混用 OPEN_COUNT/24H + AVG_DURATION/CLOSE_RATE/7D 切换为单接口同窗口
- 标题区新增 近24h / 近7日 切换按钮,默认 24h;点击后数量、均时、关闭率、合计同步刷新
- 数量含 CLOSED(与 trend 接口口径一致);均时无 closed 样本时 null → 前端 "--";关闭率 total=0 时 null → "--"
- Delivery / Production / Supply 三页文案、布局、切换交互完全一致
YY968XX 1 месяц назад
Родитель
Сommit
cf3739151c

+ 33 - 0
Web/src/views/aidop/s8/api/s8MonitoringApi.ts

@@ -122,6 +122,27 @@ export interface S8SupplyTrendData {
 	summary: S8DeliveryTrendSummary;
 }
 
+// S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1
+export type S8DomainTypeWindow = 'LAST_24H' | 'LAST_7D';
+
+export interface S8DomainTypeMetricItem {
+	key: string;
+	label: string;
+	typeCode: string;
+	total: number;
+	openCount: number;
+	closedCount: number;
+	avgProcessHours: number | null;
+	closeRate: number | null;
+}
+
+export interface S8DomainTypeMetrics {
+	domain: string;
+	window: S8DomainTypeWindow;
+	total: number;
+	items: S8DomainTypeMetricItem[];
+}
+
 export const s8MonitoringApi = {
 	summary: (params: S8MonitoringSummaryQuery = {}) =>
 		service
@@ -147,4 +168,16 @@ export const s8MonitoringApi = {
 		service
 			.get<S8SupplyTrendData>('/api/aidop/s8/monitoring/supply-trend', { params })
 			.then(unwrap),
+
+	domainTypeMetrics: (params: {
+		domain: 'DELIVERY' | 'PRODUCTION' | 'SUPPLY';
+		window?: S8DomainTypeWindow;
+		tenantId?: number;
+		factoryId?: number;
+	}) =>
+		service
+			.get<S8DomainTypeMetrics>('/api/aidop/s8/monitoring/domain-type-metrics', {
+				params: { window: 'LAST_24H', tenantId: 1, factoryId: 1, ...params },
+			})
+			.then(unwrap),
 };

+ 98 - 42
Web/src/views/aidop/s8/monitoring/S8MonitoringDeliveryPage.vue

@@ -33,8 +33,21 @@
 					<div class="section-title">
 						<div class="section-title__bar" />
 						<h2 class="section-title__text">交付异常类型</h2>
-						<!-- S8-DELIVERY-SIDEBAR-TIME-WINDOW-LABEL-1:与下方「近7日交付异常趋势」做时间窗口区分。 -->
-						<span class="anomaly-monitor__time-badge">近24h</span>
+						<!-- S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:右侧类型卡支持 近24h / 近7日 切换;同窗口同分母。 -->
+						<div class="anomaly-monitor__window-toggle" role="group" aria-label="时间窗口">
+							<button
+								type="button"
+								class="anomaly-monitor__window-btn"
+								:class="{ 'anomaly-monitor__window-btn--active': sidebarWindow === 'LAST_24H' }"
+								@click="setSidebarWindow('LAST_24H')"
+							>近24h</button>
+							<button
+								type="button"
+								class="anomaly-monitor__window-btn"
+								:class="{ 'anomaly-monitor__window-btn--active': sidebarWindow === 'LAST_7D' }"
+								@click="setSidebarWindow('LAST_7D')"
+							>近7日</button>
+						</div>
 					</div>
 
 					<div class="anomaly-monitor__type-list">
@@ -49,15 +62,14 @@
 								<span class="anomaly-type-card__count">{{ formatInteger(item.total) }}</span>
 							</div>
 							<div class="anomaly-type-card__metrics">
-								<!-- S8-MONITORING-DEMO-SEMANTICS-CLOSURE-1:cell-data 近24h 窗口下,均时/关闭率分母与待处理数量口径不一致,
-								     演示前先统一显示 `--` 避免误读,待 cell-data 接通真实近24h 指标后恢复。 -->
+								<!-- S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:均时 / 关闭率 与数量同窗口同分母。 -->
 								<div class="anomaly-type-card__metric">
 									<div class="anomaly-type-card__metric-label">均时</div>
-									<div class="anomaly-type-card__metric-value">--</div>
+									<div class="anomaly-type-card__metric-value">{{ formatHoursOrDash(item.avgProcessHours) }}</div>
 								</div>
 								<div class="anomaly-type-card__metric">
 									<div class="anomaly-type-card__metric-label">关闭率</div>
-									<div class="anomaly-type-card__metric-value anomaly-type-card__metric-value--accent">--</div>
+									<div class="anomaly-type-card__metric-value anomaly-type-card__metric-value--accent">{{ formatPercentOrDash(item.closeRate) }}</div>
 								</div>
 							</div>
 						</article>
@@ -98,8 +110,8 @@ import { computed, onMounted, reactive, shallowRef, type CSSProperties, type Com
 import { Checked, Van } from '@element-plus/icons-vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import { deepClone, createPageLayout, LAYOUT_VERSION, type S8LayoutSchema, type GridItem } from './useS8Layout';
-import { s8DeliveryMonitoringApi, type DeliveryAnomalyType } from '../api/s8DeliveryMonitoringApi';
-import { s8MonitoringApi, type S8MonitoringSummary, type S8ModuleOrderSummary, type S8DeliveryTrendData } from '../api/s8MonitoringApi';
+import { s8DeliveryMonitoringApi } from '../api/s8DeliveryMonitoringApi';
+import { s8MonitoringApi, type S8MonitoringSummary, type S8ModuleOrderSummary, type S8DeliveryTrendData, type S8DomainTypeMetrics, type S8DomainTypeWindow } from '../api/s8MonitoringApi';
 import S8MonitoringResizableShell from './components/S8MonitoringResizableShell.vue';
 import S8MonitoringModulesGrid from './components/S8MonitoringModulesGrid.vue';
 import S8MonitoringEditToolbar from './components/S8MonitoringEditToolbar.vue';
@@ -221,25 +233,43 @@ const ANOMALY_TYPES_FALLBACK: ReadonlyArray<{ key: string; label: string }> = [
 
 const effectiveStageMeta = computed(() => pageConfig.effectiveStageMeta(STAGE_META_FALLBACK));
 
-const anomalyTypes = reactive<DeliveryAnomalyType[]>(
-	ANOMALY_TYPES_FALLBACK.map((d) => ({ ...d, total: 0, avgProcessHours: 0, closeRate: 0 })),
-);
+// S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:右侧类型卡走新统一接口 /monitoring/domain-type-metrics,
+// 同窗口同分母。废弃旧 loadConfiguredAnomalyTypes(混用 OPEN_COUNT/24H + AVG_DURATION/CLOSE_RATE/7D)。
+const sidebarWindow = shallowRef<S8DomainTypeWindow>('LAST_24H');
+const sidebarMetrics = shallowRef<S8DomainTypeMetrics | null>(null);
+const anomalyTypes = computed(() => sidebarMetrics.value?.items ?? ANOMALY_TYPES_FALLBACK.map((d) => ({
+	key: d.key,
+	label: d.label,
+	typeCode: '',
+	total: 0,
+	openCount: 0,
+	closedCount: 0,
+	avgProcessHours: null as number | null,
+	closeRate: null as number | null,
+})));
+
+async function loadSidebarMetrics() {
+	try {
+		sidebarMetrics.value = await s8MonitoringApi.domainTypeMetrics({
+			domain: 'DELIVERY',
+			window: sidebarWindow.value,
+			tenantId: 1,
+			factoryId: 1,
+		});
+	} catch (err) {
+		console.error('[S8MonitoringDeliveryPage] loadSidebarMetrics failed:', err);
+		sidebarMetrics.value = null;
+	}
+}
 
-function rebuildAnomalyTypesFromConfig() {
-	const sidebarTypes = pageConfig.effectiveSidebarTypes(ANOMALY_TYPES_FALLBACK);
-	const next = sidebarTypes.map((t) => ({
-		key: t.key,
-		label: t.label,
-		total: 0,
-		avgProcessHours: 0,
-		closeRate: 0,
-	}));
-	anomalyTypes.splice(0, anomalyTypes.length, ...next);
+function setSidebarWindow(w: S8DomainTypeWindow) {
+	if (sidebarWindow.value === w) return;
+	sidebarWindow.value = w;
+	void loadSidebarMetrics();
 }
 
 async function loadPageConfig() {
 	await pageConfig.load();
-	rebuildAnomalyTypesFromConfig();
 }
 
 const moduleMap = computed(() => new Map(moduleData.list.map((m) => [m.moduleCode, m])));
@@ -294,7 +324,7 @@ const stageCards = computed(() =>
 const stageKeys = computed(() => stageCards.value.map((c) => c.code));
 
 // S8-DELIVERY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:原 categoryCards / categoryKeys 仅服务被替换的多维分析区,已移除。
-const totalAnomalies = computed(() => anomalyTypes.reduce((s, t) => s + t.total, 0));
+const totalAnomalies = computed(() => sidebarMetrics.value?.total ?? 0);
 
 // S8-DELIVERY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:近 7 日交付异常趋势数据。
 const deliveryTrendData = shallowRef<S8DeliveryTrendData | null>(null);
@@ -316,12 +346,22 @@ function formatHours(v: number) {
 	return v >= 24 ? `${Math.round(v)}h+` : `${v.toFixed(1)}h`;
 }
 function formatPercent(v: number) { return `${Math.max(0, Math.min(100, v)).toFixed(0)}%`; }
-function resolveTone(closeRate: number): 'good' | 'warn' | 'danger' {
-	if (closeRate >= 95) return 'good';
-	if (closeRate >= 80) return 'warn';
+// S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:null/未定义 → '--',避免 0h / 0% 误读为"刚好为零"。
+function formatHoursOrDash(v: number | null | undefined) {
+	if (v === null || v === undefined || !Number.isFinite(v) || v <= 0) return '--';
+	return formatHours(v);
+}
+function formatPercentOrDash(v: number | null | undefined) {
+	if (v === null || v === undefined || !Number.isFinite(v)) return '--';
+	return formatPercent(v);
+}
+function resolveTone(closeRate: number | null | undefined): 'good' | 'warn' | 'danger' {
+	const v = closeRate ?? 0;
+	if (v >= 95) return 'good';
+	if (v >= 80) return 'warn';
 	return 'danger';
 }
-function anomalyToneClass(closeRate: number) {
+function anomalyToneClass(closeRate: number | null | undefined) {
 	return `anomaly-type-card--${resolveTone(closeRate)}`;
 }
 
@@ -335,18 +375,12 @@ async function loadData() {
 	try {
 		// moduleCodes 由 page-config 派生;fallback 时 effectiveStageMeta 退到 STAGE_META_FALLBACK,行为与历史硬编码一致。
 		const moduleCodes = effectiveStageMeta.value.map((m) => m.code);
-		const [summaryData, modulesData, typesData] = await Promise.all([
+		const [summaryData, modulesData] = await Promise.all([
 			s8DeliveryMonitoringApi.summary(moduleCodes),
 			s8DeliveryMonitoringApi.modules(moduleCodes),
-			s8DeliveryMonitoringApi.anomalyTypes(),
 		]);
 		Object.assign(summary, summaryData);
 		moduleData.list = modulesData;
-		// 按 key 合并,不依赖位置(兼容 page-config 重建后的 anomalyTypes 顺序)
-		typesData.forEach((t) => {
-			const target = anomalyTypes.find((a) => a.key === t.key);
-			if (target) Object.assign(target, t);
-		});
 		loadState.value = 'ok';
 	} catch (err) {
 		console.error('[S8MonitoringDeliveryPage] loadData failed:', err);
@@ -364,9 +398,10 @@ onMounted(async () => {
 	await loadPageConfig();
 	// 2. 用最终的 stageCards 初始化 stage 配置覆盖层(applyConfig 已 passthrough,仅维护抽屉状态)
 	initializeFromCards(stageCards.value);
-	// 3. 拉业务数据 + 趋势
+	// 3. 拉业务数据 + 趋势 + 右侧类型卡(新统一接口)
 	void loadData();
 	void loadDeliveryTrend();
+	void loadSidebarMetrics();
 });
 </script>
 
@@ -567,19 +602,40 @@ onMounted(async () => {
 
 .section-title { display: flex; align-items: center; gap: 12px; }
 
-/* S8-DELIVERY-SIDEBAR-TIME-WINDOW-LABEL-1:右侧「交付异常类型」时间窗口徽标。 */
-.anomaly-monitor__time-badge {
+/* S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:右侧标题时间窗口切换按钮。 */
+.anomaly-monitor__window-toggle {
 	display: inline-flex;
 	align-items: center;
-	height: 20px;
-	padding: 0 8px;
+	gap: 0;
+	margin-left: auto;
+	padding: 2px;
+	background: rgba(11, 14, 20, 0.55);
+	border: 1px solid rgba(123, 208, 255, 0.18);
+	border-radius: 999px;
+}
+
+.anomaly-monitor__window-btn {
+	height: 22px;
+	padding: 0 10px;
 	font-size: 11px;
 	font-weight: 500;
-	color: #7bd0ff;
-	background: rgba(123, 208, 255, 0.12);
-	border: 1px solid rgba(123, 208, 255, 0.28);
+	color: #c6c6cd;
+	background: transparent;
+	border: none;
 	border-radius: 999px;
+	cursor: pointer;
 	letter-spacing: 0.04em;
+	transition: background 0.18s ease, color 0.18s ease;
+}
+
+.anomaly-monitor__window-btn:hover {
+	color: #e1e6ee;
+}
+
+.anomaly-monitor__window-btn--active {
+	color: #0b0e14;
+	background: linear-gradient(90deg, #7bd0ff 0%, #6de039 100%);
+	font-weight: 600;
 }
 
 .section-title__bar {

+ 85 - 40
Web/src/views/aidop/s8/monitoring/S8MonitoringProductionPage.vue

@@ -33,8 +33,15 @@
 					<div class="section-title">
 						<div class="section-title__bar" />
 						<h2 class="section-title__text">生产异常类型</h2>
-						<!-- S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:与下方「近7日生产异常趋势」做时间窗口区分。 -->
-						<span class="anomaly-monitor__time-badge">近24h</span>
+						<!-- S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:右侧类型卡支持 近24h / 近7日 切换;同窗口同分母。 -->
+						<div class="anomaly-monitor__window-toggle" role="group" aria-label="时间窗口">
+							<button type="button" class="anomaly-monitor__window-btn"
+								:class="{ 'anomaly-monitor__window-btn--active': sidebarWindow === 'LAST_24H' }"
+								@click="setSidebarWindow('LAST_24H')">近24h</button>
+							<button type="button" class="anomaly-monitor__window-btn"
+								:class="{ 'anomaly-monitor__window-btn--active': sidebarWindow === 'LAST_7D' }"
+								@click="setSidebarWindow('LAST_7D')">近7日</button>
+						</div>
 					</div>
 
 					<div class="anomaly-monitor__type-list">
@@ -49,15 +56,14 @@
 								<span class="anomaly-type-card__count">{{ formatInteger(item.total) }}</span>
 							</div>
 							<div class="anomaly-type-card__metrics">
-								<!-- S8-MONITORING-DEMO-SEMANTICS-CLOSURE-1:cell-data 近24h 窗口下,均时/关闭率分母与待处理数量口径不一致,
-								     演示前先统一显示 `--` 避免误读,待 cell-data 接通真实近24h 指标后恢复。 -->
+								<!-- S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:均时 / 关闭率 与数量同窗口同分母。 -->
 								<div class="anomaly-type-card__metric">
 									<div class="anomaly-type-card__metric-label">均时</div>
-									<div class="anomaly-type-card__metric-value">--</div>
+									<div class="anomaly-type-card__metric-value">{{ formatHoursOrDash(item.avgProcessHours) }}</div>
 								</div>
 								<div class="anomaly-type-card__metric">
 									<div class="anomaly-type-card__metric-label">关闭率</div>
-									<div class="anomaly-type-card__metric-value anomaly-type-card__metric-value--accent">--</div>
+									<div class="anomaly-type-card__metric-value anomaly-type-card__metric-value--accent">{{ formatPercentOrDash(item.closeRate) }}</div>
 								</div>
 							</div>
 						</article>
@@ -98,8 +104,8 @@ import { computed, onMounted, reactive, shallowRef, type CSSProperties, type Com
 import { TrendCharts, Box } from '@element-plus/icons-vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import { deepClone, createPageLayout, LAYOUT_VERSION, type S8LayoutSchema, type GridItem } from './useS8Layout';
-import { s8ProductionMonitoringApi, type ProductionAnomalyType } from '../api/s8ProductionMonitoringApi';
-import { s8MonitoringApi, type S8MonitoringSummary, type S8ModuleOrderSummary, type S8ProductionTrendData } from '../api/s8MonitoringApi';
+import { s8ProductionMonitoringApi } from '../api/s8ProductionMonitoringApi';
+import { s8MonitoringApi, type S8MonitoringSummary, type S8ModuleOrderSummary, type S8ProductionTrendData, type S8DomainTypeMetrics, type S8DomainTypeWindow } from '../api/s8MonitoringApi';
 import S8MonitoringResizableShell from './components/S8MonitoringResizableShell.vue';
 import S8MonitoringModulesGrid from './components/S8MonitoringModulesGrid.vue';
 import S8MonitoringEditToolbar from './components/S8MonitoringEditToolbar.vue';
@@ -218,25 +224,42 @@ const ANOMALY_TYPES_FALLBACK: ReadonlyArray<{ key: string; label: string }> = [
 
 const effectiveStageMeta = computed(() => pageConfig.effectiveStageMeta(STAGE_META_FALLBACK));
 
-const anomalyTypes = reactive<ProductionAnomalyType[]>(
-	ANOMALY_TYPES_FALLBACK.map((d) => ({ ...d, total: 0, avgProcessHours: 0, closeRate: 0 })),
-);
+// S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:右侧类型卡走新统一接口 /monitoring/domain-type-metrics。
+const sidebarWindow = shallowRef<S8DomainTypeWindow>('LAST_24H');
+const sidebarMetrics = shallowRef<S8DomainTypeMetrics | null>(null);
+const anomalyTypes = computed(() => sidebarMetrics.value?.items ?? ANOMALY_TYPES_FALLBACK.map((d) => ({
+	key: d.key,
+	label: d.label,
+	typeCode: '',
+	total: 0,
+	openCount: 0,
+	closedCount: 0,
+	avgProcessHours: null as number | null,
+	closeRate: null as number | null,
+})));
+
+async function loadSidebarMetrics() {
+	try {
+		sidebarMetrics.value = await s8MonitoringApi.domainTypeMetrics({
+			domain: 'PRODUCTION',
+			window: sidebarWindow.value,
+			tenantId: 1,
+			factoryId: 1,
+		});
+	} catch (err) {
+		console.error('[S8MonitoringProductionPage] loadSidebarMetrics failed:', err);
+		sidebarMetrics.value = null;
+	}
+}
 
-function rebuildAnomalyTypesFromConfig() {
-	const sidebarTypes = pageConfig.effectiveSidebarTypes(ANOMALY_TYPES_FALLBACK);
-	const next = sidebarTypes.map((t) => ({
-		key: t.key,
-		label: t.label,
-		total: 0,
-		avgProcessHours: 0,
-		closeRate: 0,
-	}));
-	anomalyTypes.splice(0, anomalyTypes.length, ...next);
+function setSidebarWindow(w: S8DomainTypeWindow) {
+	if (sidebarWindow.value === w) return;
+	sidebarWindow.value = w;
+	void loadSidebarMetrics();
 }
 
 async function loadPageConfig() {
 	await pageConfig.load();
-	rebuildAnomalyTypesFromConfig();
 }
 
 const moduleMap = computed(() => new Map(moduleData.list.map((m) => [m.moduleCode, m])));
@@ -288,7 +311,7 @@ const stageCards = computed(() =>
 const stageKeys = computed(() => stageCards.value.map((c) => c.code));
 
 // S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:原 categoryCards / categoryKeys 仅服务被替换的多维分析区,已移除。
-const totalAnomalies = computed(() => anomalyTypes.reduce((s, t) => s + t.total, 0));
+const totalAnomalies = computed(() => sidebarMetrics.value?.total ?? 0);
 
 // S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:近 7 日生产异常趋势数据。
 const productionTrendData = shallowRef<S8ProductionTrendData | null>(null);
@@ -311,12 +334,22 @@ function formatHours(v: number) {
 	return v >= 24 ? `${Math.round(v)}h+` : `${v.toFixed(1)}h`;
 }
 function formatPercent(v: number) { return `${Math.max(0, Math.min(100, v)).toFixed(0)}%`; }
-function resolveTone(closeRate: number): 'good' | 'warn' | 'danger' {
-	if (closeRate >= 95) return 'good';
-	if (closeRate >= 80) return 'warn';
+// S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:null/未定义 → '--'。
+function formatHoursOrDash(v: number | null | undefined) {
+	if (v === null || v === undefined || !Number.isFinite(v) || v <= 0) return '--';
+	return formatHours(v);
+}
+function formatPercentOrDash(v: number | null | undefined) {
+	if (v === null || v === undefined || !Number.isFinite(v)) return '--';
+	return formatPercent(v);
+}
+function resolveTone(closeRate: number | null | undefined): 'good' | 'warn' | 'danger' {
+	const v = closeRate ?? 0;
+	if (v >= 95) return 'good';
+	if (v >= 80) return 'warn';
 	return 'danger';
 }
-function anomalyToneClass(closeRate: number) {
+function anomalyToneClass(closeRate: number | null | undefined) {
 	return `anomaly-type-card--${resolveTone(closeRate)}`;
 }
 
@@ -330,17 +363,12 @@ async function loadData() {
 	try {
 		// moduleCodes 由 page-config 派生;fallback 时 effectiveStageMeta 退到 STAGE_META_FALLBACK,行为与历史硬编码一致。
 		const moduleCodes = effectiveStageMeta.value.map((m) => m.code);
-		const [summaryData, modulesData, typesData] = await Promise.all([
+		const [summaryData, modulesData] = await Promise.all([
 			s8ProductionMonitoringApi.summary(moduleCodes),
 			s8ProductionMonitoringApi.modules(moduleCodes),
-			s8ProductionMonitoringApi.anomalyTypes(),
 		]);
 		Object.assign(summary, summaryData);
 		moduleData.list = modulesData;
-		typesData.forEach((t) => {
-			const target = anomalyTypes.find((a) => a.key === t.key);
-			if (target) Object.assign(target, t);
-		});
 		loadState.value = 'ok';
 	} catch (err) {
 		console.error('[S8MonitoringProductionPage] loadData failed:', err);
@@ -358,6 +386,7 @@ onMounted(async () => {
 	initializeFromCards(stageCards.value);
 	void loadData();
 	void loadProductionTrend();
+	void loadSidebarMetrics();
 });
 </script>
 
@@ -554,19 +583,35 @@ onMounted(async () => {
 
 .section-title { display: flex; align-items: center; gap: 12px; }
 
-/* S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:右侧「生产异常类型」时间窗口徽标。 */
-.anomaly-monitor__time-badge {
+/* S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:右侧标题时间窗口切换按钮。 */
+.anomaly-monitor__window-toggle {
 	display: inline-flex;
 	align-items: center;
-	height: 20px;
-	padding: 0 8px;
+	gap: 0;
+	margin-left: auto;
+	padding: 2px;
+	background: rgba(11, 14, 20, 0.55);
+	border: 1px solid rgba(255, 193, 7, 0.22);
+	border-radius: 999px;
+}
+.anomaly-monitor__window-btn {
+	height: 22px;
+	padding: 0 10px;
 	font-size: 11px;
 	font-weight: 500;
-	color: #ffc107;
-	background: rgba(255, 193, 7, 0.12);
-	border: 1px solid rgba(255, 193, 7, 0.28);
+	color: #c6c6cd;
+	background: transparent;
+	border: none;
 	border-radius: 999px;
+	cursor: pointer;
 	letter-spacing: 0.04em;
+	transition: background 0.18s ease, color 0.18s ease;
+}
+.anomaly-monitor__window-btn:hover { color: #e1e6ee; }
+.anomaly-monitor__window-btn--active {
+	color: #0b0e14;
+	background: linear-gradient(90deg, #ffc107 0%, #ff9d6c 100%);
+	font-weight: 600;
 }
 
 .section-title__bar {

+ 85 - 40
Web/src/views/aidop/s8/monitoring/S8MonitoringSupplyPage.vue

@@ -33,8 +33,15 @@
 					<div class="section-title">
 						<div class="section-title__bar" />
 						<h2 class="section-title__text">供应异常类型</h2>
-						<!-- S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:与下方「近7日供应异常趋势」做时间窗口区分。 -->
-						<span class="anomaly-monitor__time-badge">近24h</span>
+						<!-- S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:右侧类型卡支持 近24h / 近7日 切换;同窗口同分母。 -->
+						<div class="anomaly-monitor__window-toggle" role="group" aria-label="时间窗口">
+							<button type="button" class="anomaly-monitor__window-btn"
+								:class="{ 'anomaly-monitor__window-btn--active': sidebarWindow === 'LAST_24H' }"
+								@click="setSidebarWindow('LAST_24H')">近24h</button>
+							<button type="button" class="anomaly-monitor__window-btn"
+								:class="{ 'anomaly-monitor__window-btn--active': sidebarWindow === 'LAST_7D' }"
+								@click="setSidebarWindow('LAST_7D')">近7日</button>
+						</div>
 					</div>
 
 					<div class="anomaly-monitor__type-list">
@@ -49,15 +56,14 @@
 								<span class="anomaly-type-card__count">{{ formatInteger(item.total) }}</span>
 							</div>
 							<div class="anomaly-type-card__metrics">
-								<!-- S8-SIDEBAR-TYPE-CARD-LAYOUT-UNIFY-1:与 Delivery / Production 同款 2 格 metrics;
-								     均时 / 关闭率 跨窗口分母不一致问题在 演示前 统一显示 `--`,待 cell-data 接通真实近24h 指标后恢复。 -->
+								<!-- S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:均时 / 关闭率 与数量同窗口同分母。 -->
 								<div class="anomaly-type-card__metric">
 									<div class="anomaly-type-card__metric-label">均时</div>
-									<div class="anomaly-type-card__metric-value">--</div>
+									<div class="anomaly-type-card__metric-value">{{ formatHoursOrDash(item.avgProcessHours) }}</div>
 								</div>
 								<div class="anomaly-type-card__metric">
 									<div class="anomaly-type-card__metric-label">关闭率</div>
-									<div class="anomaly-type-card__metric-value anomaly-type-card__metric-value--accent">--</div>
+									<div class="anomaly-type-card__metric-value anomaly-type-card__metric-value--accent">{{ formatPercentOrDash(item.closeRate) }}</div>
 								</div>
 							</div>
 						</article>
@@ -98,8 +104,8 @@ import { computed, onMounted, reactive, shallowRef, type CSSProperties, type Com
 import { ShoppingBag, Tools, DataAnalysis } from '@element-plus/icons-vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import { deepClone, createPageLayout, LAYOUT_VERSION, type S8LayoutSchema, type GridItem } from './useS8Layout';
-import { s8SupplyMonitoringApi, type SupplyAnomalyType } from '../api/s8SupplyMonitoringApi';
-import { s8MonitoringApi, type S8MonitoringSummary, type S8ModuleOrderSummary, type S8SupplyTrendData } from '../api/s8MonitoringApi';
+import { s8SupplyMonitoringApi } from '../api/s8SupplyMonitoringApi';
+import { s8MonitoringApi, type S8MonitoringSummary, type S8ModuleOrderSummary, type S8SupplyTrendData, type S8DomainTypeMetrics, type S8DomainTypeWindow } from '../api/s8MonitoringApi';
 import S8MonitoringResizableShell from './components/S8MonitoringResizableShell.vue';
 import S8MonitoringModulesGrid from './components/S8MonitoringModulesGrid.vue';
 import S8MonitoringEditToolbar from './components/S8MonitoringEditToolbar.vue';
@@ -229,25 +235,42 @@ const ANOMALY_TYPES_FALLBACK: ReadonlyArray<{ key: string; label: string }> = [
 
 const effectiveStageMeta = computed(() => pageConfig.effectiveStageMeta(STAGE_META_FALLBACK));
 
-const anomalyTypes = reactive<SupplyAnomalyType[]>(
-	ANOMALY_TYPES_FALLBACK.map((d) => ({ ...d, total: 0, avgProcessHours: 0, closeRate: 0 })),
-);
+// S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:右侧类型卡走新统一接口 /monitoring/domain-type-metrics。
+const sidebarWindow = shallowRef<S8DomainTypeWindow>('LAST_24H');
+const sidebarMetrics = shallowRef<S8DomainTypeMetrics | null>(null);
+const anomalyTypes = computed(() => sidebarMetrics.value?.items ?? ANOMALY_TYPES_FALLBACK.map((d) => ({
+	key: d.key,
+	label: d.label,
+	typeCode: '',
+	total: 0,
+	openCount: 0,
+	closedCount: 0,
+	avgProcessHours: null as number | null,
+	closeRate: null as number | null,
+})));
+
+async function loadSidebarMetrics() {
+	try {
+		sidebarMetrics.value = await s8MonitoringApi.domainTypeMetrics({
+			domain: 'SUPPLY',
+			window: sidebarWindow.value,
+			tenantId: 1,
+			factoryId: 1,
+		});
+	} catch (err) {
+		console.error('[S8MonitoringSupplyPage] loadSidebarMetrics failed:', err);
+		sidebarMetrics.value = null;
+	}
+}
 
-function rebuildAnomalyTypesFromConfig() {
-	const sidebarTypes = pageConfig.effectiveSidebarTypes(ANOMALY_TYPES_FALLBACK);
-	const next = sidebarTypes.map((t) => ({
-		key: t.key,
-		label: t.label,
-		total: 0,
-		avgProcessHours: 0,
-		closeRate: 0,
-	}));
-	anomalyTypes.splice(0, anomalyTypes.length, ...next);
+function setSidebarWindow(w: S8DomainTypeWindow) {
+	if (sidebarWindow.value === w) return;
+	sidebarWindow.value = w;
+	void loadSidebarMetrics();
 }
 
 async function loadPageConfig() {
 	await pageConfig.load();
-	rebuildAnomalyTypesFromConfig();
 }
 
 const moduleMap = computed(() => new Map(moduleData.list.map((m) => [m.moduleCode, m])));
@@ -300,7 +323,7 @@ const stageCards = computed(() =>
 const stageKeys = computed(() => stageCards.value.map((c) => c.code));
 
 // S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:原 categoryCards / categoryKeys 仅服务被替换的多维分析区,已移除。
-const totalAnomalies = computed(() => anomalyTypes.reduce((s, t) => s + t.total, 0));
+const totalAnomalies = computed(() => sidebarMetrics.value?.total ?? 0);
 
 // S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:近 7 日供应异常趋势数据。
 const supplyTrendData = shallowRef<S8SupplyTrendData | null>(null);
@@ -322,12 +345,22 @@ function formatHours(v: number) {
 	return v >= 24 ? `${Math.round(v)}h+` : `${v.toFixed(1)}h`;
 }
 function formatPercent(v: number) { return `${Math.max(0, Math.min(100, v)).toFixed(0)}%`; }
-function resolveTone(closeRate: number): 'good' | 'warn' | 'danger' {
-	if (closeRate >= 95) return 'good';
-	if (closeRate >= 80) return 'warn';
+// S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:null/未定义 → '--'。
+function formatHoursOrDash(v: number | null | undefined) {
+	if (v === null || v === undefined || !Number.isFinite(v) || v <= 0) return '--';
+	return formatHours(v);
+}
+function formatPercentOrDash(v: number | null | undefined) {
+	if (v === null || v === undefined || !Number.isFinite(v)) return '--';
+	return formatPercent(v);
+}
+function resolveTone(closeRate: number | null | undefined): 'good' | 'warn' | 'danger' {
+	const v = closeRate ?? 0;
+	if (v >= 95) return 'good';
+	if (v >= 80) return 'warn';
 	return 'danger';
 }
-function anomalyToneClass(closeRate: number) {
+function anomalyToneClass(closeRate: number | null | undefined) {
 	return `anomaly-type-card--${resolveTone(closeRate)}`;
 }
 
@@ -341,17 +374,12 @@ async function loadData() {
 	try {
 		// moduleCodes 由 page-config 派生;fallback 时 effectiveStageMeta 退到 STAGE_META_FALLBACK,行为与历史硬编码一致。
 		const moduleCodes = effectiveStageMeta.value.map((m) => m.code);
-		const [summaryData, modulesData, typesData] = await Promise.all([
+		const [summaryData, modulesData] = await Promise.all([
 			s8SupplyMonitoringApi.summary(moduleCodes),
 			s8SupplyMonitoringApi.modules(moduleCodes),
-			s8SupplyMonitoringApi.anomalyTypes(),
 		]);
 		Object.assign(summary, summaryData);
 		moduleData.list = modulesData;
-		typesData.forEach((t) => {
-			const target = anomalyTypes.find((a) => a.key === t.key);
-			if (target) Object.assign(target, t);
-		});
 		loadState.value = 'ok';
 	} catch (err) {
 		console.error('[S8MonitoringSupplyPage] loadData failed:', err);
@@ -369,6 +397,7 @@ onMounted(async () => {
 	initializeFromCards(stageCards.value);
 	void loadData();
 	void loadSupplyTrend();
+	void loadSidebarMetrics();
 });
 </script>
 
@@ -565,19 +594,35 @@ onMounted(async () => {
 
 .section-title { display: flex; align-items: center; gap: 12px; }
 
-/* S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:右侧「供应异常类型」时间窗口徽标。 */
-.anomaly-monitor__time-badge {
+/* S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:右侧标题时间窗口切换按钮。 */
+.anomaly-monitor__window-toggle {
 	display: inline-flex;
 	align-items: center;
-	height: 20px;
-	padding: 0 8px;
+	gap: 0;
+	margin-left: auto;
+	padding: 2px;
+	background: rgba(11, 14, 20, 0.55);
+	border: 1px solid rgba(109, 224, 57, 0.22);
+	border-radius: 999px;
+}
+.anomaly-monitor__window-btn {
+	height: 22px;
+	padding: 0 10px;
 	font-size: 11px;
 	font-weight: 500;
-	color: #6de039;
-	background: rgba(109, 224, 57, 0.12);
-	border: 1px solid rgba(109, 224, 57, 0.28);
+	color: #c6c6cd;
+	background: transparent;
+	border: none;
 	border-radius: 999px;
+	cursor: pointer;
 	letter-spacing: 0.04em;
+	transition: background 0.18s ease, color 0.18s ease;
+}
+.anomaly-monitor__window-btn:hover { color: #e1e6ee; }
+.anomaly-monitor__window-btn--active {
+	color: #0b0e14;
+	background: linear-gradient(90deg, #6de039 0%, #7bd0ff 100%);
+	font-weight: 600;
 }
 
 .section-title__bar {

+ 8 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8MonitoringController.cs

@@ -53,4 +53,12 @@ public class AdoS8MonitoringController : ControllerBase
     [HttpGet("supply-trend")]
     public async Task<IActionResult> GetSupplyTrendAsync(long tenantId = 1, long factoryId = 1, int days = 7)
         => Ok(await _svc.GetSupplyTrendAsync(tenantId, factoryId, days));
+
+    /// <summary>
+    /// S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:专题页右侧异常类型卡同窗口同分母聚合。
+    /// domain ∈ {DELIVERY, PRODUCTION, SUPPLY};window ∈ {LAST_24H(默认), LAST_7D}。
+    /// </summary>
+    [HttpGet("domain-type-metrics")]
+    public async Task<IActionResult> GetDomainTypeMetricsAsync(string domain, string window = "LAST_24H", long tenantId = 1, long factoryId = 1)
+        => Ok(await _svc.GetDomainTypeMetricsAsync(domain, window, tenantId, factoryId));
 }

+ 26 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8Dtos.cs

@@ -315,6 +315,32 @@ public class AdoS8SupplyTrendDayDto
     public int Total { get; set; }
 }
 
+/// <summary>
+/// S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:右侧异常类型卡按时间窗口切片(LAST_24H / LAST_7D)出参。
+/// 同一窗口内 total / open / closed / avgProcessHours / closeRate 共用同一分母,避免跨窗口拼接误读。
+/// </summary>
+public class AdoS8DomainTypeMetricsDto
+{
+    public string Domain { get; set; } = string.Empty;
+    public string Window { get; set; } = string.Empty;
+    public int Total { get; set; }
+    public List<AdoS8DomainTypeMetricItemDto> Items { get; set; } = new();
+}
+
+public class AdoS8DomainTypeMetricItemDto
+{
+    public string Key { get; set; } = string.Empty;
+    public string Label { get; set; } = string.Empty;
+    public string TypeCode { get; set; } = string.Empty;
+    public int Total { get; set; }
+    public int OpenCount { get; set; }
+    public int ClosedCount { get; set; }
+    /// <summary>已关闭样本均时(小时);无 closed 样本时 null,前端展示 --。</summary>
+    public double? AvgProcessHours { get; set; }
+    /// <summary>关闭率 %;total=0 时 null,前端展示 --。</summary>
+    public double? CloseRate { get; set; }
+}
+
 public class AdoS8SubmitVerificationDto
 {
     public long VerifierId { get; set; }

+ 105 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MonitoringService.cs

@@ -463,4 +463,109 @@ public class S8MonitoringService : ITransient
 
         return new AdoS8SupplyTrendDto { Days = dayList, Summary = summary };
     }
+
+    /// <summary>
+    /// S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:专题页右侧类型卡按 (domain, window) 统一聚合。
+    /// 单一时间窗口下 total / open / closed / avgProcessHours / closeRate 共用同一分母。
+    /// 公共过滤:tenant/factory + is_deleted=0 + module_code IN S1-S7 + exception_type_code IN domain 类型集 + created_at IN window。
+    /// </summary>
+    public async Task<AdoS8DomainTypeMetricsDto> GetDomainTypeMetricsAsync(string domain, string window, long tenantId = 1, long factoryId = 1)
+    {
+        var d = (domain ?? string.Empty).Trim().ToUpperInvariant();
+        var w = (window ?? string.Empty).Trim().ToUpperInvariant();
+        if (w != "LAST_24H" && w != "LAST_7D") w = "LAST_24H";
+
+        (string key, string label, string typeCode)[] specs = d switch
+        {
+            "DELIVERY" => new (string, string, string)[]
+            {
+                ("order-change",   "订单变更",     "ORDER_CHANGE"),
+                ("delivery-delay", "交期延迟",     "DELIVERY_DELAY"),
+                ("stock-pending",  "入库待发",     "PENDING_SHIPMENT"),
+            },
+            "PRODUCTION" => new (string, string, string)[]
+            {
+                ("equipment-fault", "设备异常", "EQUIP_FAULT"),
+                ("material-fault",  "物料异常", "MFG_MATERIAL_ABNORMAL"),
+                ("quality-fault",   "质量异常", "MFG_QUALITY_ABNORMAL"),
+            },
+            "SUPPLY" => new (string, string, string)[]
+            {
+                ("supplier-reply-delay", "供应商回复交期异常", "SUPPLIER_ETA_ISSUE"),
+                ("supplier-ship-fault",  "供应商发货异常",     "SUPPLIER_SHIP_ISSUE"),
+                ("warehouse-receipt",    "仓库收货异常",       "WAREHOUSE_RECEIPT_ABNORMAL"),
+                ("iqc-inspection",       "IQC 检验异常",       "IQC_ISSUE"),
+                ("warehouse-shelving",   "仓库上架入库异常",   "WH_PUTAWAY_ISSUE"),
+                ("work-order-prepare",   "仓库工单备料异常",   "WORK_ORDER_KITTING_ABNORMAL"),
+                ("work-order-issue",     "仓库工单发料异常",   "WORK_ORDER_ISSUE_ABNORMAL"),
+            },
+            _ => Array.Empty<(string, string, string)>(),
+        };
+
+        var typeCodes = specs.Select(s => s.typeCode).ToArray();
+        if (typeCodes.Length == 0)
+        {
+            return new AdoS8DomainTypeMetricsDto { Domain = d, Window = w, Total = 0, Items = new() };
+        }
+
+        DateTime from, to;
+        if (w == "LAST_7D")
+        {
+            // 与 trend 接口保持一致:[today-6, today+1)
+            var today = DateTime.Today;
+            from = today.AddDays(-6);
+            to   = today.AddDays(1);
+        }
+        else
+        {
+            // LAST_24H:滑动窗口 NOW()-24h ~ NOW()
+            var now = DateTime.Now;
+            from = now.AddHours(-24);
+            to   = now.AddMinutes(1);
+        }
+
+        var rows = await _rep.AsQueryable()
+            .Where(e => e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
+            .Where(e => S8ModuleCode.All.Contains(e.ModuleCode))
+            .Where(e => e.ExceptionTypeCode != null && typeCodes.Contains(e.ExceptionTypeCode))
+            .Where(e => e.CreatedAt >= from && e.CreatedAt < to)
+            .Select(e => new { e.ExceptionTypeCode, e.Status, e.CreatedAt, e.ClosedAt })
+            .ToListAsync();
+
+        var byType = rows.GroupBy(r => r.ExceptionTypeCode!).ToDictionary(g => g.Key, g => g.ToList());
+
+        var items = specs.Select(s =>
+        {
+            var bucket = byType.TryGetValue(s.typeCode, out var list) ? list : new();
+            var total       = bucket.Count;
+            var closedCount = bucket.Count(r => r.Status == "CLOSED");
+            var openCount   = total - closedCount;
+            double? avgHours = null;
+            var closedSamples = bucket.Where(r => r.ClosedAt.HasValue).ToList();
+            if (closedSamples.Count > 0)
+            {
+                avgHours = Math.Round(closedSamples.Average(r => (r.ClosedAt!.Value - r.CreatedAt).TotalHours), 1);
+            }
+            double? closeRate = total == 0 ? null : Math.Round(closedCount * 100.0 / total, 1);
+            return new AdoS8DomainTypeMetricItemDto
+            {
+                Key             = s.key,
+                Label           = s.label,
+                TypeCode        = s.typeCode,
+                Total           = total,
+                OpenCount       = openCount,
+                ClosedCount     = closedCount,
+                AvgProcessHours = avgHours,
+                CloseRate       = closeRate,
+            };
+        }).ToList();
+
+        return new AdoS8DomainTypeMetricsDto
+        {
+            Domain = d,
+            Window = w,
+            Total  = items.Sum(i => i.Total),
+            Items  = items,
+        };
+    }
 }