浏览代码

feat(s8): show order execution as expandable list

YY968XX 3 月之前
父节点
当前提交
b7280d8dc5

+ 5 - 6
Web/src/views/aidop/s8/monitoring/SoOrderExecutionDashboardPage.vue

@@ -10,7 +10,7 @@ import type {
 	SalesOrderExecution,
 } from '/@/views/aidop/s8/monitoring/data/order-execution/types';
 import OrderExecutionKpiCards from './components/order-execution/OrderExecutionKpiCards.vue';
-import OrderExecutionCustomerGroups from './components/order-execution/OrderExecutionCustomerGroups.vue';
+import OrderExecutionOrderList from './components/order-execution/OrderExecutionOrderList.vue';
 import OrderExecutionFiltersBar from './components/order-execution/OrderExecutionFilters.vue';
 
 const router = useRouter();
@@ -84,20 +84,19 @@ const activeTab = ref<'overview' | 'execution' | 'source-dept' | 'handle-dept'>(
 						<span class="oe-dashboard__count-divider" />
 						<div class="oe-dashboard__count">
 							<span class="oe-dashboard__count-dot oe-dashboard__count-dot--green" />
-							<span class="oe-dashboard__count-label">客户分组</span>
+							<span class="oe-dashboard__count-label">覆盖客户</span>
 							<span class="oe-dashboard__count-value">{{ customerCount }}</span>
 						</div>
 					</div>
 
 					<div class="oe-dashboard__group-title">
 						<span class="oe-dashboard__group-bar" />
-						<h2 class="oe-dashboard__group-text">客户分组</h2>
+						<h2 class="oe-dashboard__group-text">订单执行列表</h2>
 					</div>
 
-					<OrderExecutionCustomerGroups
+					<OrderExecutionOrderList
 						v-if="!isEmptyResult"
-						:customers="filteredCustomers"
-						:preview-limit="3"
+						:orders="filteredOrders"
 						@open-chain="onOpenChain"
 					/>
 					<div v-else class="oe-dashboard__empty">

+ 546 - 0
Web/src/views/aidop/s8/monitoring/components/order-execution/OrderExecutionOrderList.vue

@@ -0,0 +1,546 @@
+<script setup lang="ts" name="OrderExecutionOrderList">
+// ORDER-FLOW-OVERVIEW-LIST-EXPAND-1:统一订单列表 + 单行点击展开订单档案详情。
+// 替代客户分组卡片布局;行内展开复用 StageMatrixCard,不重写五阶段矩阵。
+import { computed, ref } from 'vue';
+import type { SalesOrderExecution } from '/@/views/aidop/s8/monitoring/data/order-execution/types';
+import StageMatrixCard from './StageMatrixCard.vue';
+
+interface Props {
+	orders: SalesOrderExecution[];
+}
+
+const props = defineProps<Props>();
+
+const emit = defineEmits<{
+	'open-chain': [order: SalesOrderExecution];
+}>();
+
+const expandedSoNo = ref<string | null>(null);
+
+function toggleExpand(soNo: string) {
+	expandedSoNo.value = expandedSoNo.value === soNo ? null : soNo;
+}
+
+function onChain(order: SalesOrderExecution) {
+	emit('open-chain', order);
+}
+
+const fmtMinutes = (v: number | null | undefined) =>
+	v == null ? '--' : `${v.toFixed(1)} 分`;
+const fmtMinutesPlain = (v: number | null | undefined) =>
+	v == null ? '--' : v.toFixed(1);
+const fmtDays = (v: number | null | undefined) =>
+	v == null ? '--' : v.toFixed(1);
+const fmtVar = (v: number | null | undefined) => {
+	if (v == null) return '--';
+	const sign = v > 0 ? '+' : '';
+	return `${sign}${v.toFixed(1)}`;
+};
+const varTone = (v: number | null | undefined): 'over' | 'under' | 'neutral' =>
+	v == null ? 'neutral' : v > 0 ? 'over' : v < 0 ? 'under' : 'neutral';
+
+const rows = computed(() =>
+	props.orders.map((o) => ({
+		raw: o,
+		so: o.soNo,
+		productName: o.productName,
+		productLine: o.productLine || '--',
+		region: o.region || '--',
+		customerName: o.customerName,
+		customerType: o.customerType,
+		priority: o.priority,
+		workflow: o.workflowStatus === 'in_progress' ? '执行中' : '已完成',
+		workflowTone: o.workflowStatus === 'in_progress' ? 'progress' : 'done',
+		currentNode: o.currentNode,
+		nodeStatus: o.nodeStatus,
+		exceptionCount: o.exceptionCount,
+		responseText: fmtMinutes(o.responseDisplayMinutes),
+		processingText: fmtMinutes(o.currentProcessingMinutes),
+		lossText: fmtMinutes(o.totalLossTime),
+	})),
+);
+</script>
+
+<template>
+	<div class="oe-order-list">
+		<div v-if="!orders.length" class="oe-order-list__empty">暂无匹配订单</div>
+		<template v-else>
+			<div
+				v-for="row in rows"
+				:key="row.so"
+				class="oe-order-list__item"
+				:class="{ 'oe-order-list__item--expanded': expandedSoNo === row.so }"
+			>
+				<div
+					class="oe-order-row"
+					role="button"
+					tabindex="0"
+					@click="toggleExpand(row.so)"
+					@keydown.enter.prevent="toggleExpand(row.so)"
+					@keydown.space.prevent="toggleExpand(row.so)"
+				>
+					<span
+						class="oe-order-row__caret"
+						:class="{ 'oe-order-row__caret--expanded': expandedSoNo === row.so }"
+						aria-hidden="true"
+					>›</span>
+					<span class="oe-order-row__so">{{ row.so }}</span>
+					<span class="oe-order-row__product">{{ row.productName }}</span>
+					<span class="oe-order-row__customer">
+						<span class="oe-order-row__customer-name">{{ row.customerName }}</span>
+						<span
+							class="oe-order-row__type"
+							:class="`oe-order-row__type--${row.customerType.toLowerCase()}`"
+						>{{ row.customerType }}</span>
+					</span>
+					<span class="oe-order-row__chip oe-order-row__chip--line">{{ row.productLine }}</span>
+					<span class="oe-order-row__chip oe-order-row__chip--region">{{ row.region }}</span>
+					<span
+						class="oe-order-row__chip oe-order-row__chip--priority"
+						:class="`oe-order-row__chip--priority-${row.priority.toLowerCase()}`"
+					>{{ row.priority }}</span>
+					<span
+						class="oe-order-row__chip oe-order-row__chip--workflow"
+						:class="`oe-order-row__chip--workflow-${row.workflowTone}`"
+					>{{ row.workflow }}</span>
+					<span
+						class="oe-order-row__chip oe-order-row__chip--node"
+						:class="`oe-order-row__chip--${row.nodeStatus}`"
+					>{{ row.currentNode }}</span>
+					<span class="oe-order-row__metric">
+						<span class="oe-order-row__metric-lbl">异常</span>
+						<span class="oe-order-row__metric-val">{{ row.exceptionCount }}</span>
+					</span>
+					<span class="oe-order-row__metric">
+						<span class="oe-order-row__metric-lbl">响应</span>
+						<span class="oe-order-row__metric-val">{{ row.responseText }}</span>
+					</span>
+					<span class="oe-order-row__metric">
+						<span class="oe-order-row__metric-lbl">处理</span>
+						<span class="oe-order-row__metric-val">{{ row.processingText }}</span>
+					</span>
+					<span class="oe-order-row__metric">
+						<span class="oe-order-row__metric-lbl">损失</span>
+						<span class="oe-order-row__metric-val">{{ row.lossText }}</span>
+					</span>
+					<button
+						type="button"
+						class="oe-order-row__chain-btn"
+						@click.stop="onChain(row.raw)"
+					>
+						链路全景 ›
+					</button>
+				</div>
+
+				<transition name="oe-expand">
+					<section
+						v-if="expandedSoNo === row.so"
+						class="oe-order-detail"
+					>
+						<div class="oe-detail-kpis">
+							<div class="oe-detail-kpi">
+								<span class="oe-detail-kpi__label">异常次数</span>
+								<span
+									class="oe-detail-kpi__value"
+									:class="row.raw.exceptionCount > 0 && 'oe-detail-kpi__value--accent'"
+								>{{ row.raw.exceptionCount }}</span>
+							</div>
+							<div class="oe-detail-kpi">
+								<span class="oe-detail-kpi__label">响应时间(min)</span>
+								<span class="oe-detail-kpi__value">{{ fmtMinutesPlain(row.raw.responseDisplayMinutes) }}</span>
+							</div>
+							<div class="oe-detail-kpi">
+								<span class="oe-detail-kpi__label">处理耗时(min)</span>
+								<span class="oe-detail-kpi__value">{{ fmtMinutesPlain(row.raw.currentProcessingMinutes) }}</span>
+							</div>
+							<div class="oe-detail-kpi">
+								<span class="oe-detail-kpi__label">总损失时间(min)</span>
+								<span
+									class="oe-detail-kpi__value"
+									:class="row.raw.totalLossTime != null && row.raw.totalLossTime > 0 && 'oe-detail-kpi__value--danger'"
+								>{{ fmtMinutesPlain(row.raw.totalLossTime) }}</span>
+							</div>
+						</div>
+
+						<div class="oe-detail-basic">
+							<header class="oe-detail-section-head">
+								<span class="oe-detail-section-bar" />
+								<h3 class="oe-detail-section-title">订单基础信息</h3>
+							</header>
+							<table class="oe-detail-basic-table">
+								<thead>
+									<tr>
+										<th>订单号</th>
+										<th>启动时间</th>
+										<th>目标周期(天)</th>
+										<th>当前历时(天)</th>
+										<th>当前节点偏差(天)</th>
+										<th>累计偏差(天)</th>
+										<th>执行状态</th>
+									</tr>
+								</thead>
+								<tbody>
+									<tr>
+										<td class="oe-detail-basic-table__so">{{ row.raw.soNo }}</td>
+										<td>{{ row.raw.orderStartDate || '--' }}</td>
+										<td>{{ fmtDays(row.raw.targetCycleDays) }}</td>
+										<td>{{ fmtDays(row.raw.currentCycleDays ?? row.raw.actualCycleDays) }}</td>
+										<td :class="`oe-var--${varTone(row.raw.nodeVarianceDays)}`">{{ fmtVar(row.raw.nodeVarianceDays) }}</td>
+										<td :class="`oe-var--${varTone(row.raw.cumulativeVarianceDays)}`">{{ fmtVar(row.raw.cumulativeVarianceDays) }}</td>
+										<td>
+											<span
+												class="oe-detail-status"
+												:class="`oe-detail-status--${row.raw.nodeStatus}`"
+											>
+												<span class="oe-detail-status__dot" />
+												{{ row.raw.exceptionStatus }}
+											</span>
+										</td>
+									</tr>
+								</tbody>
+							</table>
+						</div>
+
+						<StageMatrixCard
+							:stages="row.raw.lifecycle"
+							:focus-key="row.raw.focusNodeKey ?? row.raw.currentNodeKey ?? null"
+						/>
+					</section>
+				</transition>
+			</div>
+		</template>
+	</div>
+</template>
+
+<style scoped>
+.oe-order-list {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+
+.oe-order-list__empty {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	min-height: 160px;
+	border-radius: 14px;
+	background: var(--order-panel, rgba(25, 28, 34, 0.72));
+	border: 1px dashed var(--order-border, rgba(144, 144, 151, 0.18));
+	color: var(--order-text-secondary, #c6c6cd);
+	font-size: 13px;
+}
+
+.oe-order-list__item {
+	border-radius: 12px;
+	background: var(--order-panel, rgba(25, 28, 34, 0.72));
+	border: 1px solid var(--order-border, rgba(144, 144, 151, 0.18));
+	overflow: hidden;
+	transition: border-color 120ms ease;
+}
+
+.oe-order-list__item--expanded {
+	border-color: rgba(123, 208, 255, 0.45);
+	box-shadow: 0 0 0 1px rgba(123, 208, 255, 0.18) inset;
+}
+
+.oe-order-list__item:hover {
+	border-color: rgba(123, 208, 255, 0.32);
+}
+
+.oe-order-row {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+	padding: 10px 14px;
+	cursor: pointer;
+	user-select: none;
+	flex-wrap: wrap;
+}
+
+.oe-order-row:focus-visible {
+	outline: 2px solid rgba(123, 208, 255, 0.5);
+	outline-offset: -2px;
+}
+
+.oe-order-row__caret {
+	font-size: 14px;
+	color: var(--order-text-muted, #909097);
+	transition: transform 150ms ease, color 120ms ease;
+	display: inline-block;
+	width: 12px;
+	text-align: center;
+}
+
+.oe-order-row__caret--expanded {
+	transform: rotate(90deg);
+	color: var(--order-accent, #7bd0ff);
+}
+
+.oe-order-row__so {
+	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
+	font-size: 12px;
+	color: #7bd0ff;
+	flex: 0 0 auto;
+}
+
+.oe-order-row__product {
+	font-size: 13px;
+	font-weight: 600;
+	color: var(--order-text-primary, #e1e2eb);
+	flex: 1 1 200px;
+	min-width: 0;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.oe-order-row__customer {
+	display: inline-flex;
+	align-items: center;
+	gap: 6px;
+	flex: 0 0 auto;
+}
+
+.oe-order-row__customer-name {
+	font-size: 12px;
+	color: var(--order-text-secondary, #c6c6cd);
+}
+
+.oe-order-row__type {
+	padding: 1px 7px;
+	border-radius: 999px;
+	font-size: 10px;
+	font-weight: 600;
+	line-height: 1.4;
+}
+
+.oe-order-row__type--ka { background: rgba(123, 208, 255, 0.16); color: #7bd0ff; }
+.oe-order-row__type--smb { background: rgba(109, 224, 57, 0.14); color: #88fd54; }
+.oe-order-row__type--micro { background: rgba(255, 193, 7, 0.14); color: #ffc107; }
+
+.oe-order-row__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);
+	flex: 0 0 auto;
+}
+
+.oe-order-row__chip--line { background: rgba(123, 208, 255, 0.08); color: #7bd0ff; }
+.oe-order-row__chip--region { background: rgba(255, 193, 7, 0.08); color: #ffc107; }
+.oe-order-row__chip--priority-p1 { background: rgba(255, 180, 171, 0.16); color: #ffb4ab; font-weight: 600; }
+.oe-order-row__chip--priority-p2 { background: rgba(255, 193, 7, 0.16); color: #ffc107; font-weight: 600; }
+.oe-order-row__chip--priority-p3 { background: rgba(123, 208, 255, 0.16); color: #7bd0ff; font-weight: 600; }
+.oe-order-row__chip--workflow-progress { background: rgba(123, 208, 255, 0.12); color: #7bd0ff; }
+.oe-order-row__chip--workflow-done { background: rgba(109, 224, 57, 0.12); color: #88fd54; }
+.oe-order-row__chip--green { background: rgba(109, 224, 57, 0.12); color: #88fd54; }
+.oe-order-row__chip--yellow { background: rgba(255, 193, 7, 0.12); color: #ffc107; }
+.oe-order-row__chip--red { background: rgba(255, 180, 171, 0.14); color: #ffb4ab; }
+
+.oe-order-row__metric {
+	display: inline-flex;
+	align-items: baseline;
+	gap: 4px;
+	flex: 0 0 auto;
+}
+
+.oe-order-row__metric-lbl {
+	font-size: 10px;
+	color: var(--order-text-muted, #909097);
+}
+
+.oe-order-row__metric-val {
+	font-size: 12px;
+	font-weight: 700;
+	color: var(--order-text-primary, #e1e2eb);
+	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
+}
+
+.oe-order-row__chain-btn {
+	margin-left: auto;
+	display: inline-flex;
+	align-items: center;
+	gap: 4px;
+	padding: 3px 12px;
+	border-radius: 999px;
+	border: 1px solid rgba(123, 208, 255, 0.45);
+	background: rgba(123, 208, 255, 0.14);
+	color: #cfe9ff;
+	font-size: 12px;
+	font-weight: 600;
+	cursor: pointer;
+	flex: 0 0 auto;
+	transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
+}
+
+.oe-order-row__chain-btn:hover {
+	background: rgba(123, 208, 255, 0.32);
+	border-color: rgba(123, 208, 255, 0.7);
+	color: #ffffff;
+}
+
+.oe-order-detail {
+	display: flex;
+	flex-direction: column;
+	gap: 14px;
+	padding: 14px 16px 16px;
+	border-top: 1px solid var(--order-border, rgba(144, 144, 151, 0.18));
+	background: rgba(15, 19, 26, 0.4);
+}
+
+.oe-detail-kpis {
+	display: grid;
+	grid-template-columns: repeat(4, minmax(0, 1fr));
+	gap: 10px;
+}
+
+.oe-detail-kpi {
+	display: flex;
+	flex-direction: column;
+	gap: 4px;
+	padding: 10px 12px;
+	border-radius: 10px;
+	background: rgba(25, 28, 34, 0.6);
+	border: 1px solid rgba(69, 70, 77, 0.28);
+}
+
+.oe-detail-kpi__label {
+	font-size: 11px;
+	color: var(--order-text-muted, #909097);
+}
+
+.oe-detail-kpi__value {
+	font-size: 22px;
+	font-weight: 700;
+	color: var(--order-text-primary, #e1e2eb);
+	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
+}
+
+.oe-detail-kpi__value--accent { color: #ffc107; }
+.oe-detail-kpi__value--danger { color: #ffb4ab; }
+
+.oe-detail-section-head {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	margin-bottom: 6px;
+}
+
+.oe-detail-section-bar {
+	width: 4px;
+	height: 14px;
+	border-radius: 999px;
+	background: var(--order-accent, #7bd0ff);
+}
+
+.oe-detail-section-title {
+	margin: 0;
+	font-size: 13px;
+	font-weight: 700;
+	color: var(--order-text-primary, #e1e2eb);
+}
+
+.oe-detail-basic-table {
+	width: 100%;
+	border-collapse: collapse;
+	background: rgba(25, 28, 34, 0.55);
+	border: 1px solid rgba(69, 70, 77, 0.28);
+	border-radius: 10px;
+	overflow: hidden;
+	font-size: 12px;
+}
+
+.oe-detail-basic-table th,
+.oe-detail-basic-table td {
+	padding: 8px 10px;
+	text-align: left;
+	border-bottom: 1px solid rgba(69, 70, 77, 0.18);
+}
+
+.oe-detail-basic-table th {
+	color: var(--order-text-muted, #909097);
+	font-weight: 600;
+	background: rgba(15, 19, 26, 0.6);
+}
+
+.oe-detail-basic-table td {
+	color: var(--order-text-primary, #e1e2eb);
+	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
+}
+
+.oe-detail-basic-table__so {
+	color: #7bd0ff;
+}
+
+.oe-detail-basic-table tr:last-child td {
+	border-bottom: none;
+}
+
+.oe-var--over { color: #ffb4ab; }
+.oe-var--under { color: #88fd54; }
+.oe-var--neutral { color: var(--order-text-muted, #909097); }
+
+.oe-detail-status {
+	display: inline-flex;
+	align-items: center;
+	gap: 6px;
+	padding: 2px 10px;
+	border-radius: 999px;
+	font-size: 11px;
+	font-family: 'PingFang SC', sans-serif;
+	background: rgba(50, 53, 60, 0.6);
+}
+
+.oe-detail-status__dot {
+	width: 6px;
+	height: 6px;
+	border-radius: 999px;
+	background: currentColor;
+}
+
+.oe-detail-status--green { color: #88fd54; }
+.oe-detail-status--yellow { color: #ffc107; }
+.oe-detail-status--red { color: #ffb4ab; }
+
+.oe-expand-enter-active,
+.oe-expand-leave-active {
+	transition: max-height 200ms ease, opacity 180ms ease;
+	overflow: hidden;
+}
+
+.oe-expand-enter-from,
+.oe-expand-leave-to {
+	max-height: 0;
+	opacity: 0;
+}
+
+.oe-expand-enter-to,
+.oe-expand-leave-from {
+	max-height: 1200px;
+	opacity: 1;
+}
+
+@media (max-width: 1080px) {
+	.oe-detail-kpis {
+		grid-template-columns: repeat(2, minmax(0, 1fr));
+	}
+}
+
+@media (max-width: 768px) {
+	.oe-order-row {
+		gap: 6px;
+	}
+
+	.oe-detail-basic-table {
+		font-size: 11px;
+	}
+
+	.oe-detail-basic-table th,
+	.oe-detail-basic-table td {
+		padding: 6px 8px;
+	}
+}
+</style>