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

fix(s8): clarify chain matrix status metrics

YY968XX 2 месяцев назад
Родитель
Сommit
1eb422ff50

+ 167 - 37
Web/src/views/aidop/s8/monitoring/components/order-execution/ChainOverviewList.vue

@@ -33,9 +33,13 @@ interface ColumnAgg {
 	name: string;
 	ownerDept: string;
 	kpiText: string;
-	actualText: string;
-	statusText: string;
-	statusTone: 'green' | 'yellow' | 'red' | 'pending' | 'mixed';
+	achievementDaysText: string;
+	achievementRateText: string;
+	statusTone: 'green' | 'yellow' | 'red' | 'pending';
+	isSingleOrder: boolean;
+	singleStatusLabel: string;
+	statusCounts: { green: number; yellow: number; red: number; pending: number };
+	statusTooltip: string;
 }
 
 function pickStage(order: SalesOrderExecution, key: OrderNodeKey): StageSnapshot | undefined {
@@ -48,13 +52,22 @@ function avg(values: number[]): number | null {
 	return sum / values.length;
 }
 
+const STATUS_LABEL: Record<StageSnapshot['status'], string> = {
+	green: '正常',
+	yellow: '关注',
+	red: '严重',
+	pending: '未开始',
+};
+
 function aggregateColumn(
 	key: OrderNodeKey,
 	name: string,
 	ownerDept: string,
 	orders: SalesOrderExecution[],
 ): ColumnAgg {
+	const isSingleOrder = orders.length === 1;
 	const stages = orders.map((o) => pickStage(o, key)).filter((s): s is StageSnapshot => !!s);
+	const emptyCounts = { green: 0, yellow: 0, red: 0, pending: 0 };
 
 	if (stages.length === 0) {
 		return {
@@ -62,60 +75,73 @@ function aggregateColumn(
 			name,
 			ownerDept,
 			kpiText: '--',
-			actualText: '--',
-			statusText: '暂无',
+			achievementDaysText: '--',
+			achievementRateText: '--',
 			statusTone: 'pending',
+			isSingleOrder,
+			singleStatusLabel: '暂无',
+			statusCounts: emptyCounts,
+			statusTooltip: '暂无样本',
 		};
 	}
 
 	const kpiVals: number[] = [];
 	const actualVals: number[] = [];
+	const validKpiPairs: Array<{ actual: number; kpi: number }> = [];
 	for (const s of stages) {
 		if (s.actualDays != null && s.nodeVarianceDays != null) {
-			kpiVals.push(Number((s.actualDays - s.nodeVarianceDays).toFixed(2)));
+			const kpi = Number((s.actualDays - s.nodeVarianceDays).toFixed(2));
+			kpiVals.push(kpi);
+			if (s.status !== 'pending') {
+				validKpiPairs.push({ actual: s.actualDays, kpi });
+			}
 		}
 		if (s.actualDays != null) actualVals.push(s.actualDays);
 	}
 
 	const kpiAvg = avg(kpiVals);
 	const actualAvg = avg(actualVals);
-
-	const statusCounts = { green: 0, yellow: 0, red: 0, pending: 0 };
+	const onTimeRate =
+		validKpiPairs.length === 0
+			? null
+			: Math.round(
+					(validKpiPairs.filter((p) => p.actual <= p.kpi).length / validKpiPairs.length) * 100,
+				);
+
+	const statusCounts = { ...emptyCounts };
 	for (const s of stages) statusCounts[s.status]++;
 
-	let statusText: string;
 	let statusTone: ColumnAgg['statusTone'];
-	if (orders.length === 1) {
-		const labels: Record<StageSnapshot['status'], string> = {
-			green: '正常',
-			yellow: '关注',
-			red: '严重',
-			pending: '未开始',
-		};
+	if (isSingleOrder) {
 		statusTone = stages[0].status;
-		statusText = labels[stages[0].status];
+	} else if (onTimeRate == null) {
+		statusTone = 'pending';
+	} else if (onTimeRate >= 95) {
+		statusTone = 'green';
+	} else if (onTimeRate >= 85) {
+		statusTone = 'yellow';
 	} else {
-		const total = stages.length;
-		const parts: string[] = [];
-		if (statusCounts.green > 0) parts.push(`达成 ${statusCounts.green}/${total}`);
-		if (statusCounts.yellow > 0) parts.push(`预警 ${statusCounts.yellow}`);
-		if (statusCounts.red > 0) parts.push(`风险 ${statusCounts.red}`);
-		if (statusCounts.pending > 0) parts.push(`待启 ${statusCounts.pending}`);
-		statusText = parts.join(' · ') || '--';
-		if (statusCounts.red > 0) statusTone = 'red';
-		else if (statusCounts.yellow > 0) statusTone = 'yellow';
-		else if (statusCounts.green === total) statusTone = 'green';
-		else statusTone = 'mixed';
+		statusTone = 'red';
 	}
 
+	const tooltipParts: string[] = [];
+	if (statusCounts.green > 0) tooltipParts.push(`达成 ${statusCounts.green}`);
+	if (statusCounts.yellow > 0) tooltipParts.push(`预警 ${statusCounts.yellow}`);
+	if (statusCounts.red > 0) tooltipParts.push(`风险 ${statusCounts.red}`);
+	if (statusCounts.pending > 0) tooltipParts.push(`待启 ${statusCounts.pending}`);
+
 	return {
 		key,
 		name,
 		ownerDept,
 		kpiText: kpiAvg != null ? `${kpiAvg.toFixed(1)} 天` : '--',
-		actualText: actualAvg != null ? `${actualAvg.toFixed(1)} 天` : '--',
-		statusText,
+		achievementDaysText: actualAvg != null ? `${actualAvg.toFixed(1)} 天` : '--',
+		achievementRateText: onTimeRate != null ? `${onTimeRate}%` : '--',
 		statusTone,
+		isSingleOrder,
+		singleStatusLabel: STATUS_LABEL[stages[0].status],
+		statusCounts,
+		statusTooltip: tooltipParts.join(' / ') || '暂无',
 	};
 }
 
@@ -201,7 +227,16 @@ function onClick(key: OrderNodeKey) {
 						:key="`a-${col.key}`"
 						:class="{ 'chain-matrix__cell--active': activeStageKey === col.key }"
 					>
-						{{ col.actualText }}
+						<div class="chain-matrix__achievement">
+							<span class="chain-matrix__achievement-days">{{ col.achievementDaysText }}</span>
+							<span class="chain-matrix__achievement-sep">/</span>
+							<span
+								class="chain-matrix__achievement-rate"
+								:class="`chain-matrix__achievement-rate--${col.statusTone}`"
+							>
+								{{ col.achievementRateText }}
+							</span>
+						</div>
 					</td>
 				</tr>
 				<tr>
@@ -211,10 +246,57 @@ function onClick(key: OrderNodeKey) {
 						:key="`s-${col.key}`"
 						:class="{ 'chain-matrix__cell--active': activeStageKey === col.key }"
 					>
-						<span class="chain-matrix__status" :class="`chain-matrix__status--${col.statusTone}`">
+						<div
+							v-if="col.isSingleOrder"
+							class="chain-matrix__status chain-matrix__status--single"
+							:class="`chain-matrix__status--${col.statusTone}`"
+							:title="col.statusTooltip"
+						>
 							<span class="chain-matrix__status-dot" />
-							{{ col.statusText }}
-						</span>
+							<span>{{ col.singleStatusLabel }}</span>
+						</div>
+						<div v-else class="chain-matrix__dist" :title="col.statusTooltip">
+							<span
+								v-if="col.statusCounts.green > 0"
+								class="chain-matrix__dist-item chain-matrix__dist-item--green"
+							>
+								<span class="chain-matrix__status-dot" />
+								<span>{{ col.statusCounts.green }}</span>
+							</span>
+							<span
+								v-if="col.statusCounts.yellow > 0"
+								class="chain-matrix__dist-item chain-matrix__dist-item--yellow"
+							>
+								<span class="chain-matrix__status-dot" />
+								<span>{{ col.statusCounts.yellow }}</span>
+							</span>
+							<span
+								v-if="col.statusCounts.red > 0"
+								class="chain-matrix__dist-item chain-matrix__dist-item--red"
+							>
+								<span class="chain-matrix__status-dot" />
+								<span>{{ col.statusCounts.red }}</span>
+							</span>
+							<span
+								v-if="col.statusCounts.pending > 0"
+								class="chain-matrix__dist-item chain-matrix__dist-item--pending"
+							>
+								<span class="chain-matrix__status-dot" />
+								<span>{{ col.statusCounts.pending }}</span>
+							</span>
+							<span
+								v-if="
+									col.statusCounts.green === 0 &&
+									col.statusCounts.yellow === 0 &&
+									col.statusCounts.red === 0 &&
+									col.statusCounts.pending === 0
+								"
+								class="chain-matrix__dist-item chain-matrix__dist-item--pending"
+							>
+								<span class="chain-matrix__status-dot" />
+								<span>--</span>
+							</span>
+						</div>
 					</td>
 				</tr>
 			</tbody>
@@ -366,8 +448,8 @@ function onClick(key: OrderNodeKey) {
 }
 
 .chain-matrix__status-dot {
-	width: 6px;
-	height: 6px;
+	width: 7px;
+	height: 7px;
 	border-radius: 999px;
 	background: currentColor;
 }
@@ -376,5 +458,53 @@ function onClick(key: OrderNodeKey) {
 .chain-matrix__status--yellow { color: #ffc107; }
 .chain-matrix__status--red { color: #ffb4ab; }
 .chain-matrix__status--pending { color: var(--order-text-muted, #909097); }
-.chain-matrix__status--mixed { color: var(--order-accent, #7bd0ff); }
+
+.chain-matrix__achievement {
+	display: inline-flex;
+	align-items: baseline;
+	gap: 6px;
+	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
+}
+
+.chain-matrix__achievement-days {
+	font-size: 13px;
+	color: var(--order-text-primary, #e1e2eb);
+	font-weight: 600;
+}
+
+.chain-matrix__achievement-sep {
+	color: var(--order-text-muted, #909097);
+}
+
+.chain-matrix__achievement-rate {
+	font-size: 13px;
+	font-weight: 700;
+}
+
+.chain-matrix__achievement-rate--green { color: #88fd54; }
+.chain-matrix__achievement-rate--yellow { color: #ffc107; }
+.chain-matrix__achievement-rate--red { color: #ffb4ab; }
+.chain-matrix__achievement-rate--pending { color: var(--order-text-muted, #909097); }
+
+.chain-matrix__dist {
+	display: inline-flex;
+	flex-wrap: wrap;
+	justify-content: center;
+	gap: 8px;
+	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
+	cursor: help;
+}
+
+.chain-matrix__dist-item {
+	display: inline-flex;
+	align-items: center;
+	gap: 4px;
+	font-size: 12px;
+	font-weight: 700;
+}
+
+.chain-matrix__dist-item--green { color: #88fd54; }
+.chain-matrix__dist-item--yellow { color: #ffc107; }
+.chain-matrix__dist-item--red { color: #ffb4ab; }
+.chain-matrix__dist-item--pending { color: var(--order-text-muted, #909097); }
 </style>