Преглед изворни кода

feat(s8): enhance order execution customer cards

YY968XX пре 2 месеци
родитељ
комит
7a01f104de

+ 112 - 127
Web/src/views/aidop/s8/monitoring/components/order-execution/OrderExecutionCustomerGroups.vue

@@ -1,9 +1,11 @@
 <script setup lang="ts" name="OrderExecutionCustomerGroups">
 <script setup lang="ts" name="OrderExecutionCustomerGroups">
-import { computed } from 'vue';
+import { computed, ref } from 'vue';
 import type {
 import type {
 	CustomerGroup,
 	CustomerGroup,
 	SalesOrderExecution,
 	SalesOrderExecution,
 } from '/@/views/aidop/s8/monitoring/data/order-execution/types';
 } from '/@/views/aidop/s8/monitoring/data/order-execution/types';
+import { computeCustomerGroupStats } from '/@/views/aidop/s8/monitoring/data/order-execution/aggregation';
+import OrderExecutionOrderCard from './OrderExecutionOrderCard.vue';
 
 
 interface Props {
 interface Props {
 	customers: CustomerGroup[];
 	customers: CustomerGroup[];
@@ -22,26 +24,44 @@ function onOpenChain(order: SalesOrderExecution) {
 	emit('open-chain', order);
 	emit('open-chain', order);
 }
 }
 
 
-const customerCardData = computed(() =>
-	props.customers.map((customer) => ({
-		id: customer.id,
-		name: customer.name,
-		type: customer.type,
-		orderCount: customer.orders.length,
-		previewOrders: customer.orders.slice(0, props.previewLimit),
-	})),
-);
+const expanded = ref<Set<string>>(new Set());
 
 
-const workflowText = (status: SalesOrderExecution['workflowStatus']) =>
-	status === 'in_progress' ? '执行中' : '已完成';
+function toggleExpanded(customerId: string) {
+	const next = new Set(expanded.value);
+	if (next.has(customerId)) next.delete(customerId);
+	else next.add(customerId);
+	expanded.value = next;
+}
 
 
-const nodeStatusClass = (status: SalesOrderExecution['nodeStatus']) =>
-	`oe-order-node--${status}`;
+const fmtMinutes = (v: number | null) => (v == null ? '--' : `${v.toFixed(1)} 分`);
+
+const customerCardData = computed(() =>
+	props.customers.map((customer) => {
+		const stats = computeCustomerGroupStats(customer.orders);
+		const isExpanded = expanded.value.has(customer.id);
+		const visibleOrders = isExpanded
+			? customer.orders
+			: customer.orders.slice(0, props.previewLimit);
+		return {
+			id: customer.id,
+			name: customer.name,
+			type: customer.type,
+			isExpanded,
+			canToggle: customer.orders.length > props.previewLimit,
+			visibleOrders,
+			totalOrders: stats.totalOrders,
+			totalExceptions: stats.totalExceptions,
+			avgResponseText: fmtMinutes(stats.avgResponse),
+			avgProcessingText: fmtMinutes(stats.avgProcessing),
+			avgLossText: fmtMinutes(stats.avgLoss),
+		};
+	}),
+);
 </script>
 </script>
 
 
 <template>
 <template>
 	<div class="oe-customer-groups">
 	<div class="oe-customer-groups">
-		<div
+		<section
 			v-for="customer in customerCardData"
 			v-for="customer in customerCardData"
 			:key="customer.id"
 			:key="customer.id"
 			class="oe-customer-card"
 			class="oe-customer-card"
@@ -54,55 +74,65 @@ const nodeStatusClass = (status: SalesOrderExecution['nodeStatus']) =>
 					</span>
 					</span>
 				</div>
 				</div>
 				<div class="oe-customer-card__count">
 				<div class="oe-customer-card__count">
-					<span class="oe-customer-card__count-num">{{ customer.orderCount }}</span>
+					<span class="oe-customer-card__count-num">{{ customer.totalOrders }}</span>
 					<span class="oe-customer-card__count-label">订单</span>
 					<span class="oe-customer-card__count-label">订单</span>
 				</div>
 				</div>
 			</header>
 			</header>
 
 
-			<ul class="oe-customer-card__orders">
-				<li
-					v-for="order in customer.previewOrders"
+			<dl class="oe-customer-card__stats">
+				<div class="oe-customer-card__stat">
+					<dt>异常总数</dt>
+					<dd :class="customer.totalExceptions > 0 && 'oe-customer-card__stat-value--accent'">
+						{{ customer.totalExceptions }}
+					</dd>
+				</div>
+				<div class="oe-customer-card__stat">
+					<dt>平均响应</dt>
+					<dd>{{ customer.avgResponseText }}</dd>
+				</div>
+				<div class="oe-customer-card__stat">
+					<dt>平均处理</dt>
+					<dd>{{ customer.avgProcessingText }}</dd>
+				</div>
+				<div class="oe-customer-card__stat">
+					<dt>平均损失</dt>
+					<dd>{{ customer.avgLossText }}</dd>
+				</div>
+			</dl>
+
+			<div class="oe-customer-card__orders">
+				<OrderExecutionOrderCard
+					v-for="order in customer.visibleOrders"
 					:key="order.soNo"
 					:key="order.soNo"
-					class="oe-order-row"
+					:order="order"
+					@open-chain="onOpenChain"
+				/>
+			</div>
+
+			<footer v-if="customer.canToggle" class="oe-customer-card__footer">
+				<button
+					type="button"
+					class="oe-customer-card__toggle"
+					@click="toggleExpanded(customer.id)"
 				>
 				>
-					<div class="oe-order-row__top">
-						<span class="oe-order-row__so">{{ order.soNo }}</span>
-						<span class="oe-order-row__product">{{ order.productName }}</span>
-						<button
-							type="button"
-							class="oe-order-row__chain-btn"
-							@click="onOpenChain(order)"
-						>
-							链路全景
-						</button>
-					</div>
-					<div class="oe-order-row__bottom">
-						<span class="oe-order-row__node" :class="nodeStatusClass(order.nodeStatus)">
-							{{ order.currentNode }}
-						</span>
-						<span class="oe-order-row__exception">
-							异常 {{ order.exceptionCount }}
-						</span>
-						<span class="oe-order-row__workflow">
-							{{ workflowText(order.workflowStatus) }}
-						</span>
-					</div>
-				</li>
-			</ul>
-		</div>
+					{{ customer.isExpanded ? '收起' : `展开全部 ${customer.totalOrders} 条` }}
+				</button>
+			</footer>
+		</section>
 	</div>
 	</div>
 </template>
 </template>
 
 
 <style scoped>
 <style scoped>
 .oe-customer-groups {
 .oe-customer-groups {
 	display: grid;
 	display: grid;
-	grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+	grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
 	gap: 14px;
 	gap: 14px;
 }
 }
 
 
 .oe-customer-card {
 .oe-customer-card {
 	display: flex;
 	display: flex;
 	flex-direction: column;
 	flex-direction: column;
+	gap: 10px;
 	padding: 14px 16px;
 	padding: 14px 16px;
 	border-radius: 14px;
 	border-radius: 14px;
 	background: var(--order-panel, rgba(25, 28, 34, 0.72));
 	background: var(--order-panel, rgba(25, 28, 34, 0.72));
@@ -113,7 +143,6 @@ const nodeStatusClass = (status: SalesOrderExecution['nodeStatus']) =>
 	display: flex;
 	display: flex;
 	align-items: center;
 	align-items: center;
 	justify-content: space-between;
 	justify-content: space-between;
-	margin-bottom: 10px;
 }
 }
 
 
 .oe-customer-card__title {
 .oe-customer-card__title {
@@ -135,20 +164,9 @@ const nodeStatusClass = (status: SalesOrderExecution['nodeStatus']) =>
 	line-height: 1.4;
 	line-height: 1.4;
 }
 }
 
 
-.oe-customer-card__type--ka {
-	background: rgba(123, 208, 255, 0.16);
-	color: #7bd0ff;
-}
-
-.oe-customer-card__type--smb {
-	background: rgba(109, 224, 57, 0.14);
-	color: #88fd54;
-}
-
-.oe-customer-card__type--micro {
-	background: rgba(255, 193, 7, 0.14);
-	color: #ffc107;
-}
+.oe-customer-card__type--ka { background: rgba(123, 208, 255, 0.16); color: #7bd0ff; }
+.oe-customer-card__type--smb { background: rgba(109, 224, 57, 0.14); color: #88fd54; }
+.oe-customer-card__type--micro { background: rgba(255, 193, 7, 0.14); color: #ffc107; }
 
 
 .oe-customer-card__count {
 .oe-customer-card__count {
 	display: flex;
 	display: flex;
@@ -167,94 +185,61 @@ const nodeStatusClass = (status: SalesOrderExecution['nodeStatus']) =>
 	font-size: 12px;
 	font-size: 12px;
 }
 }
 
 
-.oe-customer-card__orders {
+.oe-customer-card__stats {
+	display: grid;
+	grid-template-columns: repeat(4, minmax(0, 1fr));
+	gap: 6px;
 	margin: 0;
 	margin: 0;
-	padding: 0;
-	list-style: none;
-	display: flex;
-	flex-direction: column;
-	gap: 8px;
-}
-
-.oe-order-row {
 	padding: 8px 10px;
 	padding: 8px 10px;
 	border-radius: 10px;
 	border-radius: 10px;
 	background: rgba(15, 19, 26, 0.55);
 	background: rgba(15, 19, 26, 0.55);
-	border: 1px solid rgba(69, 70, 77, 0.28);
+	border: 1px solid rgba(69, 70, 77, 0.18);
 }
 }
 
 
-.oe-order-row__top {
+.oe-customer-card__stat {
 	display: flex;
 	display: flex;
-	align-items: center;
-	gap: 10px;
-	margin-bottom: 4px;
-}
-
-.oe-order-row__chain-btn {
-	margin-left: auto;
-	padding: 2px 10px;
-	border-radius: 999px;
-	border: 1px solid rgba(123, 208, 255, 0.32);
-	background: rgba(123, 208, 255, 0.08);
-	color: #7bd0ff;
-	font-size: 11px;
-	cursor: pointer;
-	flex: 0 0 auto;
+	flex-direction: column;
+	gap: 2px;
+	margin: 0;
 }
 }
 
 
-.oe-order-row__chain-btn:hover {
-	background: rgba(123, 208, 255, 0.18);
+.oe-customer-card__stat dt {
+	font-size: 10px;
+	color: var(--order-text-muted, #909097);
 }
 }
 
 
-.oe-order-row__so {
+.oe-customer-card__stat dd {
+	margin: 0;
+	font-size: 13px;
+	font-weight: 700;
+	color: var(--order-text-primary, #e1e2eb);
 	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
 	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
-	font-size: 12px;
-	color: #7bd0ff;
 }
 }
 
 
-.oe-order-row__product {
-	font-size: 13px;
-	color: var(--order-text-primary, #e1e2eb);
-	overflow: hidden;
-	text-overflow: ellipsis;
-	white-space: nowrap;
+.oe-customer-card__stat-value--accent { color: #ffc107; }
+
+.oe-customer-card__orders {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
 }
 }
 
 
-.oe-order-row__bottom {
+.oe-customer-card__footer {
 	display: flex;
 	display: flex;
-	align-items: center;
-	gap: 12px;
-	font-size: 11px;
-	color: var(--order-text-secondary, #c6c6cd);
+	justify-content: center;
 }
 }
 
 
-.oe-order-row__node {
-	padding: 1px 8px;
+.oe-customer-card__toggle {
+	padding: 4px 14px;
 	border-radius: 999px;
 	border-radius: 999px;
+	border: 1px solid rgba(123, 208, 255, 0.28);
+	background: rgba(123, 208, 255, 0.06);
+	color: #7bd0ff;
 	font-size: 11px;
 	font-size: 11px;
+	cursor: pointer;
 }
 }
 
 
-.oe-order-node--green {
-	background: rgba(109, 224, 57, 0.12);
-	color: #88fd54;
-}
-
-.oe-order-node--yellow {
-	background: rgba(255, 193, 7, 0.12);
-	color: #ffc107;
-}
-
-.oe-order-node--red {
-	background: rgba(255, 180, 171, 0.14);
-	color: #ffb4ab;
-}
-
-.oe-order-row__exception {
-	color: #ffc107;
-}
-
-.oe-order-row__workflow {
-	margin-left: auto;
-	color: var(--order-text-muted, #909097);
+.oe-customer-card__toggle:hover {
+	background: rgba(123, 208, 255, 0.16);
 }
 }
 </style>
 </style>

+ 233 - 0
Web/src/views/aidop/s8/monitoring/components/order-execution/OrderExecutionOrderCard.vue

@@ -0,0 +1,233 @@
+<script setup lang="ts" name="OrderExecutionOrderCard">
+// ORDER-FLOW-OVERVIEW-CUSTOMER-CARDS-1:订单摘要卡(信息密度增强 + 链路按钮)。
+import { computed } from 'vue';
+import type { SalesOrderExecution } from '/@/views/aidop/s8/monitoring/data/order-execution/types';
+
+interface Props {
+	order: SalesOrderExecution;
+	canOpenChain?: boolean;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+	canOpenChain: true,
+});
+
+const emit = defineEmits<{
+	'open-chain': [order: SalesOrderExecution];
+}>();
+
+const fmtMinutes = (v: number | null | undefined) => (v == null ? '--' : `${v.toFixed(1)} 分`);
+
+const view = computed(() => {
+	const o = props.order;
+	return {
+		so: o.soNo,
+		productName: o.productName,
+		productLine: o.productLine || '--',
+		region: o.region || '--',
+		priority: o.priority,
+		workflow: o.workflowStatus === 'in_progress' ? '执行中' : '已完成',
+		workflowTone: o.workflowStatus === 'in_progress' ? 'progress' : 'done',
+		currentNode: o.currentNode,
+		nodeStatus: o.nodeStatus,
+		exceptionCount: o.exceptionCount,
+		response: fmtMinutes(o.responseDisplayMinutes),
+		processing: fmtMinutes(o.currentProcessingMinutes),
+		loss: fmtMinutes(o.totalLossTime),
+	};
+});
+
+function onChain() {
+	if (!props.canOpenChain) return;
+	emit('open-chain', props.order);
+}
+</script>
+
+<template>
+	<article class="oe-order-card">
+		<header class="oe-order-card__head">
+			<div class="oe-order-card__title">
+				<span class="oe-order-card__so">{{ view.so }}</span>
+				<span class="oe-order-card__product">{{ view.productName }}</span>
+			</div>
+			<div class="oe-order-card__actions">
+				<span class="oe-order-card__priority" :class="`oe-order-card__priority--${view.priority.toLowerCase()}`">
+					{{ view.priority }}
+				</span>
+				<span class="oe-order-card__workflow" :class="`oe-order-card__workflow--${view.workflowTone}`">
+					{{ view.workflow }}
+				</span>
+				<button
+					v-if="canOpenChain"
+					type="button"
+					class="oe-order-card__chain-btn"
+					@click="onChain"
+				>
+					链路全景
+				</button>
+			</div>
+		</header>
+
+		<div class="oe-order-card__meta">
+			<span class="oe-order-card__chip oe-order-card__chip--line">{{ view.productLine }}</span>
+			<span class="oe-order-card__chip oe-order-card__chip--region">{{ view.region }}</span>
+			<span class="oe-order-card__chip oe-order-card__chip--node" :class="`oe-order-card__chip--${view.nodeStatus}`">
+				{{ view.currentNode }}
+			</span>
+			<span class="oe-order-card__chip oe-order-card__chip--exception">
+				异常 {{ view.exceptionCount }}
+			</span>
+		</div>
+
+		<dl class="oe-order-card__metrics">
+			<div class="oe-order-card__metric">
+				<dt>响应</dt>
+				<dd>{{ view.response }}</dd>
+			</div>
+			<div class="oe-order-card__metric">
+				<dt>处理</dt>
+				<dd>{{ view.processing }}</dd>
+			</div>
+			<div class="oe-order-card__metric">
+				<dt>损失</dt>
+				<dd>{{ view.loss }}</dd>
+			</div>
+		</dl>
+	</article>
+</template>
+
+<style scoped>
+.oe-order-card {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+	padding: 10px 12px;
+	border-radius: 10px;
+	background: rgba(15, 19, 26, 0.55);
+	border: 1px solid rgba(69, 70, 77, 0.28);
+}
+
+.oe-order-card__head {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 10px;
+}
+
+.oe-order-card__title {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	min-width: 0;
+}
+
+.oe-order-card__so {
+	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
+	font-size: 12px;
+	color: #7bd0ff;
+	flex: 0 0 auto;
+}
+
+.oe-order-card__product {
+	font-size: 13px;
+	font-weight: 600;
+	color: var(--order-text-primary, #e1e2eb);
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.oe-order-card__actions {
+	display: flex;
+	align-items: center;
+	gap: 6px;
+	flex: 0 0 auto;
+}
+
+.oe-order-card__priority {
+	padding: 1px 8px;
+	border-radius: 999px;
+	font-size: 11px;
+	line-height: 1.6;
+	font-weight: 600;
+}
+
+.oe-order-card__priority--p1 { background: rgba(255, 180, 171, 0.16); color: #ffb4ab; }
+.oe-order-card__priority--p2 { background: rgba(255, 193, 7, 0.16); color: #ffc107; }
+.oe-order-card__priority--p3 { background: rgba(123, 208, 255, 0.16); color: #7bd0ff; }
+
+.oe-order-card__workflow {
+	padding: 1px 8px;
+	border-radius: 999px;
+	font-size: 11px;
+	line-height: 1.6;
+}
+
+.oe-order-card__workflow--progress { background: rgba(123, 208, 255, 0.12); color: #7bd0ff; }
+.oe-order-card__workflow--done { background: rgba(109, 224, 57, 0.12); color: #88fd54; }
+
+.oe-order-card__chain-btn {
+	padding: 2px 10px;
+	border-radius: 999px;
+	border: 1px solid rgba(123, 208, 255, 0.32);
+	background: rgba(123, 208, 255, 0.08);
+	color: #7bd0ff;
+	font-size: 11px;
+	cursor: pointer;
+}
+
+.oe-order-card__chain-btn:hover {
+	background: rgba(123, 208, 255, 0.18);
+}
+
+.oe-order-card__meta {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 6px;
+}
+
+.oe-order-card__chip {
+	padding: 1px 8px;
+	border-radius: 999px;
+	font-size: 11px;
+	line-height: 1.6;
+	background: rgba(50, 53, 60, 0.6);
+	color: var(--order-text-secondary, #c6c6cd);
+}
+
+.oe-order-card__chip--line { background: rgba(123, 208, 255, 0.08); color: #7bd0ff; }
+.oe-order-card__chip--region { background: rgba(255, 193, 7, 0.08); color: #ffc107; }
+.oe-order-card__chip--green { background: rgba(109, 224, 57, 0.12); color: #88fd54; }
+.oe-order-card__chip--yellow { background: rgba(255, 193, 7, 0.12); color: #ffc107; }
+.oe-order-card__chip--red { background: rgba(255, 180, 171, 0.14); color: #ffb4ab; }
+.oe-order-card__chip--exception { background: rgba(255, 193, 7, 0.08); color: #ffc107; }
+
+.oe-order-card__metrics {
+	display: grid;
+	grid-template-columns: repeat(3, minmax(0, 1fr));
+	gap: 6px;
+	margin: 0;
+	padding-top: 4px;
+	border-top: 1px solid rgba(69, 70, 77, 0.18);
+}
+
+.oe-order-card__metric {
+	display: flex;
+	flex-direction: column;
+	gap: 1px;
+	margin: 0;
+}
+
+.oe-order-card__metric dt {
+	font-size: 11px;
+	color: var(--order-text-muted, #909097);
+}
+
+.oe-order-card__metric dd {
+	margin: 0;
+	font-size: 13px;
+	color: var(--order-text-primary, #e1e2eb);
+	font-weight: 600;
+	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
+}
+</style>

+ 21 - 0
Web/src/views/aidop/s8/monitoring/data/order-execution/aggregation.ts

@@ -75,3 +75,24 @@ export const createDefaultFilters = (): OrderExecutionFilters => ({
 	startDate: null,
 	startDate: null,
 	endDate: null,
 	endDate: null,
 });
 });
+
+// ORDER-FLOW-OVERVIEW-CUSTOMER-CARDS-1:客户卡聚合统计(基于传入 orders,调用方负责传 filteredOrders 子集)。
+export interface CustomerGroupStats {
+	totalOrders: number;
+	totalExceptions: number;
+	avgResponse: number | null;
+	avgProcessing: number | null;
+	avgLoss: number | null;
+}
+
+export const computeCustomerGroupStats = (orders: SalesOrderExecution[]): CustomerGroupStats => ({
+	totalOrders: orders.length,
+	totalExceptions: orders.reduce((sum, o) => sum + (o.exceptionCount ?? 0), 0),
+	avgResponse: getAverage(
+		orders.map((o) => o.responseDisplayMinutes).filter((v): v is number => v !== null),
+	),
+	avgProcessing: getAverage(
+		orders.map((o) => o.currentProcessingMinutes).filter((v): v is number => v !== null),
+	),
+	avgLoss: getAverage(orders.map((o) => o.totalLossTime).filter((v): v is number => v !== null)),
+});