Jelajahi Sumber

feat(s9): show QDC ratio KPI summary cards

YY968XX 5 bulan lalu
induk
melakukan
5b3bc43568

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

@@ -168,6 +168,23 @@ export interface S8QdcSummaryData {
 	items: S8QdcSummaryItem[];
 	items: S8QdcSummaryItem[];
 }
 }
 
 
+// TASK-012-S9-QDC-RATIO-FRONTEND-1:result KPI(达成率,与 exception-side qdc-summary 不同语义)
+export interface S8ResultKpiItem {
+	metricCode: string;
+	metricName: string;
+	objectCode: string;
+	unit: string;
+	currentValue: number | null;
+	targetRatio: number | null;
+	dictionaryEnabled: boolean;
+	remark: string | null;
+}
+
+export interface S8ResultKpiSummary {
+	items: S8ResultKpiItem[];
+	source: 'DICTIONARY_MOCK' | 'REAL' | string;
+}
+
 export const s8MonitoringApi = {
 export const s8MonitoringApi = {
 	summary: (params: S8MonitoringSummaryQuery = {}) =>
 	summary: (params: S8MonitoringSummaryQuery = {}) =>
 		service
 		service
@@ -213,4 +230,11 @@ export const s8MonitoringApi = {
 				params: { tenantId: 1, factoryId: 1, ...params },
 				params: { tenantId: 1, factoryId: 1, ...params },
 			})
 			})
 			.then(unwrap),
 			.then(unwrap),
+
+	resultKpiSummary: (params: { tenantId?: number; factoryId?: number } = {}) =>
+		service
+			.get<S8ResultKpiSummary>('/api/aidop/s8/monitoring/result-kpi-summary', {
+				params: { tenantId: 1, factoryId: 1, ...params },
+			})
+			.then(unwrap),
 };
 };

+ 36 - 1
Web/src/views/aidop/s8/monitoring/S8MonitoringOverviewPage.vue

@@ -11,6 +11,15 @@
 			<button type="button" class="s8-monitor__error-retry" @click="loadData">重试</button>
 			<button type="button" class="s8-monitor__error-retry" @click="loadData">重试</button>
 		</div>
 		</div>
 
 
+		<section class="s8-monitor__result-kpi">
+			<S9ResultKpiCard
+				:items="resultKpiItems"
+				:source="resultKpiSource"
+				:loading="resultKpiState === 'loading'"
+				:error="resultKpiState === 'error' ? resultKpiError : null"
+			/>
+		</section>
+
 		<S8MonitoringResizableShell
 		<S8MonitoringResizableShell
 			:shell="draftLayout.shell"
 			:shell="draftLayout.shell"
 			:editable="editMode"
 			:editable="editMode"
@@ -74,6 +83,7 @@ import {
 	type S8MonitoringSummary,
 	type S8MonitoringSummary,
 	type S8OrderGridData,
 	type S8OrderGridData,
 	type S8QdcSummaryItem,
 	type S8QdcSummaryItem,
+	type S8ResultKpiItem,
 } from '../api/s8MonitoringApi';
 } from '../api/s8MonitoringApi';
 import { deepClone, useS8Layout } from './useS8Layout';
 import { deepClone, useS8Layout } from './useS8Layout';
 import type { S8LayoutSchema } from './useS8Layout';
 import type { S8LayoutSchema } from './useS8Layout';
@@ -82,6 +92,7 @@ import S8MonitoringResizableShell from './components/S8MonitoringResizableShell.
 import S8MonitoringModulesGrid, { type SummaryCardData } from './components/S8MonitoringModulesGrid.vue';
 import S8MonitoringModulesGrid, { type SummaryCardData } from './components/S8MonitoringModulesGrid.vue';
 import S8MonitoringEditToolbar from './components/S8MonitoringEditToolbar.vue';
 import S8MonitoringEditToolbar from './components/S8MonitoringEditToolbar.vue';
 import S8MonitoringStageConfigDrawer from './components/S8MonitoringStageConfigDrawer.vue';
 import S8MonitoringStageConfigDrawer from './components/S8MonitoringStageConfigDrawer.vue';
+import S9ResultKpiCard from './components/S9ResultKpiCard.vue';
 import { useS8StageConfig, buildModuleTriColorStats, DEFAULT_NORMAL_WORK_ORDER_COUNT, ORDER_STATUS_MOCK } from './useS8StageConfig';
 import { useS8StageConfig, buildModuleTriColorStats, DEFAULT_NORMAL_WORK_ORDER_COUNT, ORDER_STATUS_MOCK } from './useS8StageConfig';
 import { useS8CategoryConfig } from './useS8CategoryConfig';
 import { useS8CategoryConfig } from './useS8CategoryConfig';
 import { useS9DeptConfig } from './useS9DeptConfig';
 import { useS9DeptConfig } from './useS9DeptConfig';
@@ -590,6 +601,26 @@ const loadState = shallowRef<'idle' | 'loading' | 'ok' | 'error'>('idle');
 const loadError = shallowRef<string>('');
 const loadError = shallowRef<string>('');
 const qdcItems = shallowRef<S8QdcSummaryItem[]>([]);
 const qdcItems = shallowRef<S8QdcSummaryItem[]>([]);
 
 
+const resultKpiItems = shallowRef<S8ResultKpiItem[]>([]);
+const resultKpiSource = shallowRef<string>('DICTIONARY_MOCK');
+const resultKpiState = shallowRef<'idle' | 'loading' | 'ok' | 'error'>('idle');
+const resultKpiError = shallowRef<string>('');
+
+async function loadResultKpi() {
+	resultKpiState.value = 'loading';
+	resultKpiError.value = '';
+	try {
+		const data = await s8MonitoringApi.resultKpiSummary({ tenantId: 1, factoryId: 1 });
+		resultKpiItems.value = data.items ?? [];
+		resultKpiSource.value = data.source ?? 'DICTIONARY_MOCK';
+		resultKpiState.value = 'ok';
+	} catch (err) {
+		console.error('[S8MonitoringOverviewPage] loadResultKpi failed:', err);
+		resultKpiState.value = 'error';
+		resultKpiError.value = err instanceof Error ? err.message : '加载失败';
+	}
+}
+
 async function loadData() {
 async function loadData() {
 	loadState.value = 'loading';
 	loadState.value = 'loading';
 	loadError.value = '';
 	loadError.value = '';
@@ -650,7 +681,7 @@ onMounted(async () => {
 	// initializeFromCards 因 initialized=true 跳过,导致真实 API 数据无法覆盖。
 	// initializeFromCards 因 initialized=true 跳过,导致真实 API 数据无法覆盖。
 	// 改用 reset(无条件用最新 cards 重建 state.items),每次进入本页都对齐 API。
 	// 改用 reset(无条件用最新 cards 重建 state.items),每次进入本页都对齐 API。
 	await loadPageConfig();
 	await loadPageConfig();
-	await loadData();
+	await Promise.all([loadData(), loadResultKpi()]);
 	resetStageConfigState(stageCards.value);
 	resetStageConfigState(stageCards.value);
 	resetCategoryConfigState(categoryCards.value);
 	resetCategoryConfigState(categoryCards.value);
 	resetDeptConfigState(deptCards.value);
 	resetDeptConfigState(deptCards.value);
@@ -737,6 +768,10 @@ watch(deptCards, (depts) => {
 	pointer-events: none;
 	pointer-events: none;
 }
 }
 
 
+.s8-monitor__result-kpi {
+	margin: 44px 0 12px;
+}
+
 .s8-monitor__period-filter {
 .s8-monitor__period-filter {
 	position: absolute;
 	position: absolute;
 	top: 10px;
 	top: 10px;

+ 179 - 0
Web/src/views/aidop/s8/monitoring/components/S9ResultKpiCard.vue

@@ -0,0 +1,179 @@
+<script setup lang="ts">
+import { computed } from 'vue';
+import type { S8ResultKpiItem } from '../../api/s8MonitoringApi';
+
+const props = defineProps<{
+	items: S8ResultKpiItem[];
+	source: string;
+	loading?: boolean;
+	error?: string | null;
+	minHeight?: number;
+}>();
+
+const cardStyle = computed(() => ({
+	minHeight: `${Math.max(140, props.minHeight ?? 180)}px`,
+}));
+
+const isMock = computed(() => props.source === 'DICTIONARY_MOCK');
+
+function fmtCurrent(item: S8ResultKpiItem): string {
+	if (item.currentValue === null || item.currentValue === undefined) return '待接入';
+	const unit = item.unit || '%';
+	return `${item.currentValue.toFixed(1)}${unit}`;
+}
+
+function fmtTarget(item: S8ResultKpiItem): string {
+	if (item.targetRatio === null || item.targetRatio === undefined) return '目标 待配置';
+	const unit = item.unit || '%';
+	return `目标 ${item.targetRatio.toFixed(1)}${unit}`;
+}
+</script>
+
+<template>
+	<article class="s9-result-kpi" :style="cardStyle">
+		<div class="s9-result-kpi__header">
+			<span class="s9-result-kpi__code">S9</span>
+			<span class="s9-result-kpi__title">结果 KPI</span>
+			<span v-if="isMock" class="s9-result-kpi__sub">字典占位 · 待接入真实计算</span>
+		</div>
+		<div v-if="loading" class="s9-result-kpi__placeholder">加载中…</div>
+		<div v-else-if="error" class="s9-result-kpi__placeholder s9-result-kpi__placeholder--err">
+			加载失败:{{ error }}
+		</div>
+		<div v-else-if="!items.length" class="s9-result-kpi__placeholder">暂无 result KPI 字典数据</div>
+		<div v-else class="s9-result-kpi__tracks">
+			<div v-for="item in items" :key="item.metricCode" class="s9-result-kpi__track">
+				<div class="s9-result-kpi__track-head">
+					<span class="s9-result-kpi__track-name">{{ item.metricName }}</span>
+					<span v-if="!item.dictionaryEnabled" class="s9-result-kpi__badge">未启用</span>
+				</div>
+				<div class="s9-result-kpi__track-body">
+					<span
+						class="s9-result-kpi__track-val"
+						:class="{ 's9-result-kpi__track-val--mock': item.currentValue === null }"
+					>{{ fmtCurrent(item) }}</span>
+				</div>
+				<div class="s9-result-kpi__track-foot">{{ fmtTarget(item) }}</div>
+			</div>
+		</div>
+	</article>
+</template>
+
+<style scoped>
+.s9-result-kpi {
+	position: relative;
+	overflow: hidden;
+	border-radius: 18px;
+	width: 100%;
+	box-sizing: border-box;
+	padding: 14px 16px 12px;
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+	background: rgba(22, 26, 36, 0.85);
+	border: 1px solid rgba(123, 208, 255, 0.14);
+}
+
+.s9-result-kpi__header {
+	display: flex;
+	align-items: baseline;
+	gap: 8px;
+	flex-shrink: 0;
+}
+
+.s9-result-kpi__code {
+	font-size: 11px;
+	font-weight: 700;
+	color: rgba(123, 208, 255, 0.7);
+	letter-spacing: 0.08em;
+}
+
+.s9-result-kpi__title {
+	font-size: 13px;
+	font-weight: 700;
+	color: #e1e2eb;
+}
+
+.s9-result-kpi__sub {
+	font-size: 10px;
+	color: rgba(255, 193, 7, 0.75);
+	margin-left: 4px;
+	letter-spacing: 0.02em;
+}
+
+.s9-result-kpi__placeholder {
+	flex: 1;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	color: #909097;
+	font-size: 12px;
+}
+
+.s9-result-kpi__placeholder--err {
+	color: #ffb4ab;
+}
+
+.s9-result-kpi__tracks {
+	display: grid;
+	grid-template-columns: 1fr 1fr;
+	gap: 8px;
+	flex: 1;
+	min-height: 0;
+}
+
+.s9-result-kpi__track {
+	background: rgba(255, 255, 255, 0.03);
+	border: 1px solid rgba(255, 255, 255, 0.06);
+	border-radius: 10px;
+	padding: 8px 10px;
+	display: flex;
+	flex-direction: column;
+	gap: 4px;
+}
+
+.s9-result-kpi__track-head {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 8px;
+}
+
+.s9-result-kpi__track-name {
+	font-size: 11px;
+	font-weight: 600;
+	color: rgba(123, 208, 255, 0.75);
+}
+
+.s9-result-kpi__badge {
+	padding: 1px 6px;
+	border-radius: 999px;
+	font-size: 9px;
+	color: rgba(255, 193, 7, 0.85);
+	background: rgba(255, 193, 7, 0.1);
+	border: 1px solid rgba(255, 193, 7, 0.2);
+}
+
+.s9-result-kpi__track-body {
+	display: flex;
+	align-items: baseline;
+}
+
+.s9-result-kpi__track-val {
+	font-size: 18px;
+	font-weight: 700;
+	color: #c6c6cd;
+	line-height: 1.2;
+}
+
+.s9-result-kpi__track-val--mock {
+	font-size: 12px;
+	color: #909097;
+	font-weight: 600;
+}
+
+.s9-result-kpi__track-foot {
+	font-size: 10px;
+	color: #909097;
+}
+</style>