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

fix(s8): merge result KPI into S9 operation card

YY968XX 5 месяцев назад
Родитель
Сommit
e903481a21

+ 4 - 14
Web/src/views/aidop/s8/monitoring/S8MonitoringOverviewPage.vue

@@ -11,15 +11,6 @@
 			<button type="button" class="s8-monitor__error-retry" @click="loadData">重试</button>
 		</div>
 
-		<section class="s8-monitor__result-kpi">
-			<S9ResultKpiCard
-				:items="resultKpiItems"
-				:source="resultKpiSource"
-				:loading="resultKpiState === 'loading'"
-				:error="resultKpiState === 'error' ? resultKpiError : null"
-			/>
-		</section>
-
 		<S8MonitoringResizableShell
 			:shell="draftLayout.shell"
 			:editable="editMode"
@@ -33,6 +24,10 @@
 						:cards="allStageCards"
 						:summary-cards="allSummaryCards"
 						:qdc-items="qdcItems"
+						:result-kpi-items="resultKpiItems"
+						:result-kpi-source="resultKpiSource"
+						:result-kpi-loading="resultKpiState === 'loading'"
+						:result-kpi-error="resultKpiState === 'error' ? resultKpiError : null"
 						:layout="draftLayout.modules"
 						:editable="editMode"
 						:row-height="60"
@@ -92,7 +87,6 @@ import S8MonitoringResizableShell from './components/S8MonitoringResizableShell.
 import S8MonitoringModulesGrid, { type SummaryCardData } from './components/S8MonitoringModulesGrid.vue';
 import S8MonitoringEditToolbar from './components/S8MonitoringEditToolbar.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 { useS8CategoryConfig } from './useS8CategoryConfig';
 import { useS9DeptConfig } from './useS9DeptConfig';
@@ -768,10 +762,6 @@ watch(deptCards, (depts) => {
 	pointer-events: none;
 }
 
-.s8-monitor__result-kpi {
-	margin: 44px 0 12px;
-}
-
 .s8-monitor__period-filter {
 	position: absolute;
 	top: 10px;

+ 15 - 3
Web/src/views/aidop/s8/monitoring/components/S8MonitoringModulesGrid.vue

@@ -27,8 +27,16 @@
         :static="!editable"
       >
         <S9QdcSummaryCard
-          v-if="item.i === 'S9' && qdcItems && qdcItems.length > 0"
-          :items="qdcItems"
+          v-if="item.i === 'S9' && (
+            (qdcItems && qdcItems.length > 0) ||
+            (resultKpiItems && resultKpiItems.length > 0) ||
+            resultKpiLoading || !!resultKpiError
+          )"
+          :items="qdcItems ?? []"
+          :result-kpi-items="resultKpiItems"
+          :result-kpi-source="resultKpiSource"
+          :result-kpi-loading="resultKpiLoading"
+          :result-kpi-error="resultKpiError"
           :min-height="item.h * rowHeight - gap * 2"
           style="width:100%;height:100%"
         />
@@ -60,7 +68,7 @@ import S8MonitoringSummaryCard, { type SummaryMetric } from './S8MonitoringSumma
 import S9QdcSummaryCard from './S9QdcSummaryCard.vue'
 import type { ModuleGridItem } from '../useS8Layout'
 import type { Component } from 'vue'
-import type { S8QdcSummaryItem } from '../../api/s8MonitoringApi'
+import type { S8QdcSummaryItem, S8ResultKpiItem } from '../../api/s8MonitoringApi'
 
 interface StageCardData {
   code: string
@@ -107,6 +115,10 @@ const props = defineProps<{
   cards: StageCardData[]
   summaryCards?: SummaryCardData[]
   qdcItems?: S8QdcSummaryItem[]
+  resultKpiItems?: S8ResultKpiItem[]
+  resultKpiSource?: string
+  resultKpiLoading?: boolean
+  resultKpiError?: string | null
   layout: ModuleGridItem[]
   editable: boolean
   rowHeight?: number

+ 226 - 88
Web/src/views/aidop/s8/monitoring/components/S9QdcSummaryCard.vue

@@ -1,16 +1,28 @@
 <script setup lang="ts">
-import { computed } from 'vue';
-import type { S8QdcSummaryItem } from '../../api/s8MonitoringApi';
+import { computed, ref } from 'vue';
+import type { S8QdcSummaryItem, S8ResultKpiItem } from '../../api/s8MonitoringApi';
 
 const props = defineProps<{
 	items: S8QdcSummaryItem[];
+	resultKpiItems?: S8ResultKpiItem[];
+	resultKpiSource?: string;
+	resultKpiLoading?: boolean;
+	resultKpiError?: string | null;
 	minHeight?: number;
 }>();
 
+type ViewKey = 'resultKpi' | 'exception';
+const activeView = ref<ViewKey>('resultKpi');
+
 const cardStyle = computed(() => ({
 	minHeight: `${Math.max(120, props.minHeight ?? 160)}px`,
 }));
 
+const isMock = computed(() => props.resultKpiSource === 'DICTIONARY_MOCK');
+const resultKpiList = computed(() => props.resultKpiItems ?? []);
+const hasResultKpi = computed(() => resultKpiList.value.length > 0);
+const hasException = computed(() => props.items.length > 0);
+
 function fmtHours(val: number | null | undefined): string {
 	if (val === null || val === undefined) return '--';
 	return `${val.toFixed(1)}h`;
@@ -20,133 +32,259 @@ function fmtRate(val: number | null | undefined): string {
 	if (val === null || val === undefined) return '--';
 	return `${val.toFixed(0)}%`;
 }
+
+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-qdc-card" :style="cardStyle">
-    <div class="s9-qdc-card__header">
-      <span class="s9-qdc-card__code">S9</span>
-      <span class="s9-qdc-card__title">运营指标</span>
-    </div>
-    <div class="s9-qdc-card__tracks">
-      <div v-for="item in items" :key="item.code" class="s9-qdc-track">
-        <div class="s9-qdc-track__name">{{ item.title }}</div>
-        <div class="s9-qdc-track__metrics">
-          <div class="s9-qdc-track__metric">
-            <span class="s9-qdc-track__metric-val">{{ item.total }}</span>
-            <span class="s9-qdc-track__metric-lbl">异常总数</span>
-          </div>
-          <div class="s9-qdc-track__metric">
-            <span class="s9-qdc-track__metric-val">{{ fmtHours(item.avgProcessHours) }}</span>
-            <span class="s9-qdc-track__metric-lbl">平均处理</span>
-          </div>
-          <div class="s9-qdc-track__metric">
-            <span class="s9-qdc-track__metric-val">{{ fmtRate(item.onTimeCloseRate) }}</span>
-            <span class="s9-qdc-track__metric-lbl">及时关闭率</span>
-          </div>
-          <div class="s9-qdc-track__metric">
-            <span class="s9-qdc-track__metric-val">{{ fmtRate(item.closeRate) }}</span>
-            <span class="s9-qdc-track__metric-lbl">总关闭率</span>
-          </div>
-        </div>
-      </div>
-    </div>
-  </article>
+	<article class="s9-qdc-card" :style="cardStyle">
+		<div class="s9-qdc-card__header">
+			<span class="s9-qdc-card__code">S9</span>
+			<span class="s9-qdc-card__title">运营指标</span>
+			<span
+				v-if="activeView === 'resultKpi' && isMock"
+				class="s9-qdc-card__sub"
+			>字典占位 · 待接入真实计算</span>
+			<div class="s9-qdc-card__tabs">
+				<button
+					type="button"
+					class="s9-qdc-card__tab"
+					:class="{ 's9-qdc-card__tab--active': activeView === 'resultKpi' }"
+					@click="activeView = 'resultKpi'"
+				>结果 KPI</button>
+				<button
+					type="button"
+					class="s9-qdc-card__tab"
+					:class="{ 's9-qdc-card__tab--active': activeView === 'exception' }"
+					@click="activeView = 'exception'"
+				>异常态势</button>
+			</div>
+		</div>
+
+		<template v-if="activeView === 'resultKpi'">
+			<div v-if="resultKpiLoading" class="s9-qdc-card__placeholder">加载中…</div>
+			<div
+				v-else-if="resultKpiError"
+				class="s9-qdc-card__placeholder s9-qdc-card__placeholder--err"
+			>加载失败:{{ resultKpiError }}</div>
+			<div v-else-if="!hasResultKpi" class="s9-qdc-card__placeholder">暂无 result KPI 字典数据</div>
+			<div v-else class="s9-qdc-card__tracks">
+				<div v-for="item in resultKpiList" :key="item.metricCode" class="s9-qdc-track s9-qdc-track--kpi">
+					<div class="s9-qdc-track__kpi-head">
+						<span class="s9-qdc-track__name">{{ item.metricName }}</span>
+						<span v-if="!item.dictionaryEnabled" class="s9-qdc-card__badge">未启用</span>
+					</div>
+					<div
+						class="s9-qdc-track__main-val"
+						:class="{ 's9-qdc-track__main-val--mock': item.currentValue === null }"
+					>{{ fmtCurrent(item) }}</div>
+					<div class="s9-qdc-track__foot">{{ fmtTarget(item) }}</div>
+				</div>
+			</div>
+		</template>
+
+		<template v-else>
+			<div v-if="!hasException" class="s9-qdc-card__placeholder">暂无异常态势数据</div>
+			<div v-else class="s9-qdc-card__tracks">
+				<div v-for="item in items" :key="item.code" class="s9-qdc-track">
+					<div class="s9-qdc-track__name">{{ item.title }}</div>
+					<div class="s9-qdc-track__metrics">
+						<div class="s9-qdc-track__metric">
+							<span class="s9-qdc-track__metric-val">{{ item.total }}</span>
+							<span class="s9-qdc-track__metric-lbl">异常总数</span>
+						</div>
+						<div class="s9-qdc-track__metric">
+							<span class="s9-qdc-track__metric-val">{{ fmtHours(item.avgProcessHours) }}</span>
+							<span class="s9-qdc-track__metric-lbl">平均处理</span>
+						</div>
+						<div class="s9-qdc-track__metric">
+							<span class="s9-qdc-track__metric-val">{{ fmtRate(item.onTimeCloseRate) }}</span>
+							<span class="s9-qdc-track__metric-lbl">及时关闭率</span>
+						</div>
+						<div class="s9-qdc-track__metric">
+							<span class="s9-qdc-track__metric-val">{{ fmtRate(item.closeRate) }}</span>
+							<span class="s9-qdc-track__metric-lbl">总关闭率</span>
+						</div>
+					</div>
+				</div>
+			</div>
+		</template>
+	</article>
 </template>
 
 <style scoped>
 .s9-qdc-card {
-  position: relative;
-  overflow: hidden;
-  border-radius: 18px;
-  width: 100%;
-  height: 100%;
-  box-sizing: border-box;
-  padding: 14px 16px 12px;
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-  background: rgba(22, 26, 36, 0.85);
-  border: 1px solid rgba(123, 208, 255, 0.14);
+	position: relative;
+	overflow: hidden;
+	border-radius: 18px;
+	width: 100%;
+	height: 100%;
+	box-sizing: border-box;
+	padding: 14px 16px 12px;
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+	background: rgba(22, 26, 36, 0.85);
+	border: 1px solid rgba(123, 208, 255, 0.14);
 }
 
 .s9-qdc-card__header {
-  display: flex;
-  align-items: baseline;
-  gap: 6px;
-  flex-shrink: 0;
+	display: flex;
+	align-items: center;
+	gap: 6px;
+	flex-shrink: 0;
 }
 
 .s9-qdc-card__code {
-  font-size: 11px;
-  font-weight: 700;
-  color: rgba(123, 208, 255, 0.7);
-  letter-spacing: 0.08em;
+	font-size: 11px;
+	font-weight: 700;
+	color: rgba(123, 208, 255, 0.7);
+	letter-spacing: 0.08em;
 }
 
 .s9-qdc-card__title {
-  font-size: 13px;
-  font-weight: 700;
-  color: #e1e2eb;
+	font-size: 13px;
+	font-weight: 700;
+	color: #e1e2eb;
 }
 
 .s9-qdc-card__sub {
-  font-size: 10px;
-  color: #909097;
-  margin-left: 2px;
+	font-size: 10px;
+	color: rgba(255, 193, 7, 0.75);
+	margin-left: 2px;
+	letter-spacing: 0.02em;
+}
+
+.s9-qdc-card__tabs {
+	margin-left: auto;
+	display: flex;
+	gap: 4px;
+}
+
+.s9-qdc-card__tab {
+	padding: 2px 8px;
+	border-radius: 999px;
+	font-size: 10px;
+	color: #909097;
+	background: rgba(255, 255, 255, 0.04);
+	border: 1px solid rgba(255, 255, 255, 0.06);
+	cursor: pointer;
+	line-height: 16px;
+}
+
+.s9-qdc-card__tab:hover {
+	color: #c6c6cd;
+	background: rgba(255, 255, 255, 0.07);
+}
+
+.s9-qdc-card__tab--active {
+	color: rgba(123, 208, 255, 0.95);
+	background: rgba(123, 208, 255, 0.12);
+	border-color: rgba(123, 208, 255, 0.32);
+}
+
+.s9-qdc-card__placeholder {
+	flex: 1;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	color: #909097;
+	font-size: 11px;
+}
+
+.s9-qdc-card__placeholder--err {
+	color: #ffb4ab;
+}
+
+.s9-qdc-card__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-qdc-card__tracks {
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  gap: 6px;
-  flex: 1;
-  min-height: 0;
+	display: grid;
+	grid-template-columns: 1fr 1fr;
+	gap: 6px;
+	flex: 1;
+	min-height: 0;
 }
 
 .s9-qdc-track {
-  background: rgba(255, 255, 255, 0.03);
-  border: 1px solid rgba(255, 255, 255, 0.06);
-  border-radius: 10px;
-  padding: 7px 8px 6px;
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
+	background: rgba(255, 255, 255, 0.03);
+	border: 1px solid rgba(255, 255, 255, 0.06);
+	border-radius: 10px;
+	padding: 7px 8px 6px;
+	display: flex;
+	flex-direction: column;
+	gap: 4px;
+}
+
+.s9-qdc-track__kpi-head {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 6px;
 }
 
 .s9-qdc-track__name {
-  font-size: 11px;
-  font-weight: 600;
-  color: rgba(123, 208, 255, 0.75);
+	font-size: 11px;
+	font-weight: 600;
+	color: rgba(123, 208, 255, 0.75);
 }
 
 .s9-qdc-track__metrics {
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  gap: 3px 6px;
+	display: grid;
+	grid-template-columns: 1fr 1fr;
+	gap: 3px 6px;
 }
 
 .s9-qdc-track__metric {
-  display: flex;
-  flex-direction: column;
-  gap: 1px;
+	display: flex;
+	flex-direction: column;
+	gap: 1px;
 }
 
 .s9-qdc-track__metric-val {
-  font-size: 13px;
-  font-weight: 700;
-  color: #c6c6cd;
-  line-height: 1.2;
+	font-size: 13px;
+	font-weight: 700;
+	color: #c6c6cd;
+	line-height: 1.2;
 }
 
 .s9-qdc-track__metric-lbl {
-  font-size: 9px;
-  color: #909097;
+	font-size: 9px;
+	color: #909097;
+}
+
+.s9-qdc-track__main-val {
+	font-size: 16px;
+	font-weight: 700;
+	color: #c6c6cd;
+	line-height: 1.2;
+}
+
+.s9-qdc-track__main-val--mock {
+	font-size: 11px;
+	color: #909097;
+	font-weight: 600;
 }
 
-.s9-qdc-track__remark {
-  font-size: 9px;
-  color: rgba(144, 144, 151, 0.55);
-  margin-top: 2px;
+.s9-qdc-track__foot {
+	font-size: 9px;
+	color: #909097;
 }
 </style>

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

@@ -1,179 +0,0 @@
-<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>