Parcourir la source

feat(s8): enrich order execution chain details

YY968XX il y a 4 mois
Parent
commit
1a5b8a4ab3

+ 25 - 0
Web/src/views/aidop/s8/monitoring/OrderChainOverviewPage.vue

@@ -7,6 +7,8 @@ import { useRouter } from 'vue-router';
 import { useOrderExecutionStore } from '/@/stores/orderExecution';
 import StageMatrixCard from './components/order-execution/StageMatrixCard.vue';
 import NodeDetailCard from './components/order-execution/NodeDetailCard.vue';
+import ReviewSubStepsCard from './components/order-execution/ReviewSubStepsCard.vue';
+import ReviewBreakdownCard from './components/order-execution/ReviewBreakdownCard.vue';
 
 const store = useOrderExecutionStore();
 const router = useRouter();
@@ -33,6 +35,19 @@ const focusStage = computed(() => {
 
 const stages = computed(() => order.value?.lifecycle ?? []);
 
+// 评审子节点:order_review 阶段(无论 focus 是否在它)的 subSteps;用于稳定展示。
+const reviewStage = computed(() =>
+	(order.value?.lifecycle ?? []).find((s) => s.key === 'order_review') ?? null,
+);
+const reviewSubSteps = computed(() => reviewStage.value?.subSteps ?? null);
+const opinionSubStep = computed(
+	() => reviewSubSteps.value?.find((s) => s.name === '意见评审') ?? null,
+);
+const opinionBreakdown = computed(() => opinionSubStep.value?.breakdown ?? null);
+
+const showReviewSubSteps = computed(() => (reviewSubSteps.value?.length ?? 0) > 0);
+const showBreakdown = computed(() => (opinionBreakdown.value?.length ?? 0) > 0);
+
 function goBack() {
 	if (window.history.length > 1) {
 		router.back();
@@ -87,6 +102,16 @@ function goBack() {
 
 			<StageMatrixCard :stages="stages" :focus-key="focusStage?.key ?? null" />
 			<NodeDetailCard :stage="focusStage" />
+			<ReviewSubStepsCard
+				v-if="showReviewSubSteps"
+				:sub-steps="reviewSubSteps"
+				title="订单评审 · 五子节点"
+			/>
+			<ReviewBreakdownCard
+				v-if="showBreakdown"
+				:breakdown="opinionBreakdown"
+				title="意见评审 · 四方明细"
+			/>
 		</template>
 
 		<section v-else class="oe-chain__empty">

+ 25 - 6
Web/src/views/aidop/s8/monitoring/components/order-execution/NodeDetailCard.vue

@@ -16,6 +16,9 @@ const STATUS_LABEL: Record<StageSnapshot['status'], string> = {
 	pending: '未开始',
 };
 
+const fmtVar = (v: number | null | undefined) =>
+	v == null ? '--' : `${v > 0 ? '+' : ''}${v.toFixed(1)} 天`;
+
 const detail = computed(() => {
 	const s = props.stage;
 	if (!s) return null;
@@ -24,12 +27,11 @@ const detail = computed(() => {
 		targetDate: s.targetDate || '--',
 		actualReachedAt: s.actualReachedAt ?? '尚未达成',
 		actualDays: s.actualDays != null ? `${s.actualDays.toFixed(1)} 天` : '--',
-		varianceText:
-			s.nodeVarianceDays == null
-				? '--'
-				: (s.nodeVarianceDays > 0 ? '+' : '') + s.nodeVarianceDays.toFixed(1) + ' 天',
+		varianceText: fmtVar(s.nodeVarianceDays),
+		cumulativeText: fmtVar(s.cumulativeVarianceDays),
 		statusLabel: STATUS_LABEL[s.status],
 		status: s.status,
+		subStepCount: s.subSteps?.length ?? 0,
 	};
 });
 </script>
@@ -46,6 +48,9 @@ const detail = computed(() => {
 				<span class="oe-node-detail__chip" :class="`oe-node-detail__chip--${detail.status}`">
 					{{ detail.statusLabel }}
 				</span>
+				<span v-if="detail.subStepCount > 0" class="oe-node-detail__sub-count">
+					子节点 {{ detail.subStepCount }}
+				</span>
 			</div>
 			<dl class="oe-node-detail__grid">
 				<div class="oe-node-detail__cell">
@@ -61,9 +66,13 @@ const detail = computed(() => {
 					<dd>{{ detail.actualDays }}</dd>
 				</div>
 				<div class="oe-node-detail__cell">
-					<dt>偏差</dt>
+					<dt>节点偏差</dt>
 					<dd>{{ detail.varianceText }}</dd>
 				</div>
+				<div class="oe-node-detail__cell">
+					<dt>累计偏差</dt>
+					<dd>{{ detail.cumulativeText }}</dd>
+				</div>
 			</dl>
 		</div>
 		<div v-else class="oe-node-detail__empty">未找到当前节点。</div>
@@ -149,9 +158,19 @@ const detail = computed(() => {
 	color: var(--order-text-muted, #909097);
 }
 
+.oe-node-detail__sub-count {
+	padding: 1px 10px;
+	border-radius: 999px;
+	font-size: 11px;
+	line-height: 1.6;
+	font-weight: 400;
+	background: rgba(123, 208, 255, 0.12);
+	color: var(--order-accent, #7bd0ff);
+}
+
 .oe-node-detail__grid {
 	display: grid;
-	grid-template-columns: repeat(4, minmax(0, 1fr));
+	grid-template-columns: repeat(5, minmax(0, 1fr));
 	gap: 10px;
 	margin: 0;
 }

+ 203 - 0
Web/src/views/aidop/s8/monitoring/components/order-execution/ReviewBreakdownCard.vue

@@ -0,0 +1,203 @@
+<script setup lang="ts" name="ReviewBreakdownCard">
+// ORDER-FLOW-CHAIN-DETAILS-1:意见评审四方明细。
+// fixture 中 order_review.subSteps[0](意见评审)的 breakdown 为 4 个部门记录。
+import { computed } from 'vue';
+import type { SubStepDetail } from '/@/views/aidop/s8/monitoring/data/order-execution/types';
+
+interface Props {
+	breakdown: SubStepDetail[] | undefined | null;
+	title?: string;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+	title: '意见评审四方明细',
+});
+
+const STATUS_LABEL: Record<SubStepDetail['status'], string> = {
+	green: '正常',
+	yellow: '关注',
+	red: '严重',
+	pending: '未开始',
+};
+
+const rows = computed(() =>
+	(props.breakdown ?? []).map((dept) => {
+		const variance =
+			dept.actualHours == null ? null : Number((dept.actualHours - dept.piHours).toFixed(2));
+		return {
+			name: dept.name,
+			piText: `${dept.piHours.toFixed(1)} h`,
+			actualText: dept.actualHours == null ? '--' : `${dept.actualHours.toFixed(1)} h`,
+			status: dept.status,
+			statusLabel: STATUS_LABEL[dept.status],
+			varianceText:
+				variance == null ? '--' : `${variance > 0 ? '+' : ''}${variance.toFixed(2)} h`,
+			varianceTone: variance == null ? 'neutral' : variance > 0 ? 'over' : 'under',
+		};
+	}),
+);
+</script>
+
+<template>
+	<section class="oe-breakdown">
+		<header class="oe-breakdown__head">
+			<span class="oe-breakdown__bar" />
+			<h2 class="oe-breakdown__title">{{ title }}</h2>
+		</header>
+		<div v-if="rows.length === 0" class="oe-breakdown__empty">暂无四方评审明细</div>
+		<div v-else class="oe-breakdown__grid">
+			<div
+				v-for="row in rows"
+				:key="row.name"
+				class="oe-breakdown__cell"
+				:class="`oe-breakdown__cell--${row.status}`"
+			>
+				<div class="oe-breakdown__name">
+					{{ row.name }}
+					<span class="oe-breakdown__chip" :class="`oe-breakdown__chip--${row.status}`">
+						{{ row.statusLabel }}
+					</span>
+				</div>
+				<dl class="oe-breakdown__metrics">
+					<div class="oe-breakdown__metric">
+						<dt>PI 工时</dt>
+						<dd>{{ row.piText }}</dd>
+					</div>
+					<div class="oe-breakdown__metric">
+						<dt>实际</dt>
+						<dd>{{ row.actualText }}</dd>
+					</div>
+					<div class="oe-breakdown__metric">
+						<dt>偏差</dt>
+						<dd :class="`oe-breakdown__variance--${row.varianceTone}`">
+							{{ row.varianceText }}
+						</dd>
+					</div>
+				</dl>
+			</div>
+		</div>
+	</section>
+</template>
+
+<style scoped>
+.oe-breakdown {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+}
+
+.oe-breakdown__head {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+}
+
+.oe-breakdown__bar {
+	width: 5px;
+	height: 18px;
+	border-radius: 999px;
+	background: var(--order-accent, #7bd0ff);
+}
+
+.oe-breakdown__title {
+	margin: 0;
+	font-size: 16px;
+	font-weight: 700;
+}
+
+.oe-breakdown__empty {
+	padding: 14px 16px;
+	border-radius: 12px;
+	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;
+	text-align: center;
+}
+
+.oe-breakdown__grid {
+	display: grid;
+	grid-template-columns: repeat(4, minmax(0, 1fr));
+	gap: 12px;
+}
+
+.oe-breakdown__cell {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+	padding: 12px 14px;
+	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));
+	border-left-width: 3px;
+}
+
+.oe-breakdown__cell--green { border-left-color: #88fd54; }
+.oe-breakdown__cell--yellow { border-left-color: #ffc107; }
+.oe-breakdown__cell--red { border-left-color: #ffb4ab; }
+.oe-breakdown__cell--pending { border-left-color: rgba(144, 144, 151, 0.35); }
+
+.oe-breakdown__name {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	font-size: 13px;
+	font-weight: 700;
+	color: var(--order-text-primary, #e1e2eb);
+}
+
+.oe-breakdown__chip {
+	padding: 1px 8px;
+	border-radius: 999px;
+	font-size: 11px;
+	line-height: 1.6;
+	font-weight: 400;
+}
+
+.oe-breakdown__chip--green { background: rgba(109, 224, 57, 0.12); color: #88fd54; }
+.oe-breakdown__chip--yellow { background: rgba(255, 193, 7, 0.12); color: #ffc107; }
+.oe-breakdown__chip--red { background: rgba(255, 180, 171, 0.12); color: #ffb4ab; }
+.oe-breakdown__chip--pending { background: rgba(144, 144, 151, 0.12); color: var(--order-text-muted, #909097); }
+
+.oe-breakdown__metrics {
+	display: grid;
+	grid-template-columns: repeat(3, minmax(0, 1fr));
+	gap: 6px;
+	margin: 0;
+}
+
+.oe-breakdown__metric {
+	display: flex;
+	flex-direction: column;
+	gap: 2px;
+	margin: 0;
+}
+
+.oe-breakdown__metric dt {
+	font-size: 11px;
+	color: var(--order-text-muted, #909097);
+}
+
+.oe-breakdown__metric dd {
+	margin: 0;
+	font-size: 13px;
+	font-weight: 600;
+	color: var(--order-text-primary, #e1e2eb);
+}
+
+.oe-breakdown__variance--over { color: #ffb4ab; }
+.oe-breakdown__variance--under { color: #88fd54; }
+.oe-breakdown__variance--neutral { color: var(--order-text-muted, #909097); }
+
+@media (max-width: 1080px) {
+	.oe-breakdown__grid {
+		grid-template-columns: repeat(2, minmax(0, 1fr));
+	}
+}
+
+@media (max-width: 640px) {
+	.oe-breakdown__grid {
+		grid-template-columns: 1fr;
+	}
+}
+</style>

+ 169 - 0
Web/src/views/aidop/s8/monitoring/components/order-execution/ReviewSubStepsCard.vue

@@ -0,0 +1,169 @@
+<script setup lang="ts" name="ReviewSubStepsCard">
+// ORDER-FLOW-CHAIN-DETAILS-1:订单评审五子节点表(仅 order_review 阶段有 subSteps)。
+import { computed } from 'vue';
+import type { SubStepDetail } from '/@/views/aidop/s8/monitoring/data/order-execution/types';
+
+interface Props {
+	subSteps: SubStepDetail[] | undefined | null;
+	title?: string;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+	title: '评审子节点',
+});
+
+const STATUS_LABEL: Record<SubStepDetail['status'], string> = {
+	green: '正常',
+	yellow: '关注',
+	red: '严重',
+	pending: '未开始',
+};
+
+const rows = computed(() =>
+	(props.subSteps ?? []).map((step) => {
+		const variance =
+			step.actualHours == null ? null : Number((step.actualHours - step.piHours).toFixed(2));
+		return {
+			name: step.name,
+			piText: `${step.piHours.toFixed(1)} h`,
+			actualText: step.actualHours == null ? '--' : `${step.actualHours.toFixed(1)} h`,
+			status: step.status,
+			statusLabel: STATUS_LABEL[step.status],
+			varianceText:
+				variance == null ? '--' : `${variance > 0 ? '+' : ''}${variance.toFixed(2)} h`,
+			varianceTone:
+				variance == null ? 'neutral' : variance > 0 ? 'over' : 'under',
+		};
+	}),
+);
+</script>
+
+<template>
+	<section class="oe-substeps">
+		<header class="oe-substeps__head">
+			<span class="oe-substeps__bar" />
+			<h2 class="oe-substeps__title">{{ title }}</h2>
+		</header>
+		<div v-if="rows.length === 0" class="oe-substeps__empty">暂无子节点明细</div>
+		<table v-else class="oe-substeps__table">
+			<thead>
+				<tr>
+					<th class="oe-substeps__th">子节点</th>
+					<th class="oe-substeps__th">PI 工时</th>
+					<th class="oe-substeps__th">实际工时</th>
+					<th class="oe-substeps__th">偏差</th>
+					<th class="oe-substeps__th">状态</th>
+				</tr>
+			</thead>
+			<tbody>
+				<tr
+					v-for="row in rows"
+					:key="row.name"
+					class="oe-substeps__row"
+					:class="`oe-substeps__row--${row.status}`"
+				>
+					<td class="oe-substeps__td oe-substeps__td--name">{{ row.name }}</td>
+					<td class="oe-substeps__td">{{ row.piText }}</td>
+					<td class="oe-substeps__td">{{ row.actualText }}</td>
+					<td class="oe-substeps__td" :class="`oe-substeps__td--${row.varianceTone}`">
+						{{ row.varianceText }}
+					</td>
+					<td class="oe-substeps__td">
+						<span class="oe-substeps__chip" :class="`oe-substeps__chip--${row.status}`">
+							{{ row.statusLabel }}
+						</span>
+					</td>
+				</tr>
+			</tbody>
+		</table>
+	</section>
+</template>
+
+<style scoped>
+.oe-substeps {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+}
+
+.oe-substeps__head {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+}
+
+.oe-substeps__bar {
+	width: 5px;
+	height: 18px;
+	border-radius: 999px;
+	background: var(--order-accent, #7bd0ff);
+}
+
+.oe-substeps__title {
+	margin: 0;
+	font-size: 16px;
+	font-weight: 700;
+}
+
+.oe-substeps__empty {
+	padding: 14px 16px;
+	border-radius: 12px;
+	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;
+	text-align: center;
+}
+
+.oe-substeps__table {
+	width: 100%;
+	border-collapse: collapse;
+	border-radius: 12px;
+	overflow: hidden;
+	background: var(--order-panel, rgba(25, 28, 34, 0.72));
+	border: 1px solid var(--order-border, rgba(144, 144, 151, 0.18));
+	font-size: 12px;
+}
+
+.oe-substeps__th {
+	text-align: left;
+	padding: 10px 12px;
+	font-weight: 700;
+	color: var(--order-text-secondary, #c6c6cd);
+	background: rgba(15, 19, 26, 0.55);
+	border-bottom: 1px solid var(--order-border, rgba(144, 144, 151, 0.18));
+}
+
+.oe-substeps__row {
+	transition: background 80ms ease;
+}
+
+.oe-substeps__row + .oe-substeps__row .oe-substeps__td {
+	border-top: 1px solid rgba(144, 144, 151, 0.08);
+}
+
+.oe-substeps__td {
+	padding: 10px 12px;
+	color: var(--order-text-primary, #e1e2eb);
+}
+
+.oe-substeps__td--name {
+	font-weight: 600;
+}
+
+.oe-substeps__td--over { color: #ffb4ab; }
+.oe-substeps__td--under { color: #88fd54; }
+.oe-substeps__td--neutral { color: var(--order-text-muted, #909097); }
+
+.oe-substeps__chip {
+	padding: 1px 10px;
+	border-radius: 999px;
+	font-size: 11px;
+	line-height: 1.6;
+}
+
+.oe-substeps__chip--green { background: rgba(109, 224, 57, 0.12); color: #88fd54; }
+.oe-substeps__chip--yellow { background: rgba(255, 193, 7, 0.12); color: #ffc107; }
+.oe-substeps__chip--red { background: rgba(255, 180, 171, 0.12); color: #ffb4ab; }
+.oe-substeps__chip--pending { background: rgba(144, 144, 151, 0.12); color: var(--order-text-muted, #909097); }
+</style>

+ 52 - 10
Web/src/views/aidop/s8/monitoring/components/order-execution/StageMatrixCard.vue

@@ -18,6 +18,14 @@ const STATUS_LABEL: Record<StageSnapshot['status'], string> = {
 	pending: '未开始',
 };
 
+const fmtVar = (v: number | null | undefined) => {
+	if (v == null) return '--';
+	const sign = v > 0 ? '+' : '';
+	return `${sign}${v.toFixed(1)}`;
+};
+const tone = (v: number | null | undefined): 'over' | 'under' | 'neutral' =>
+	v == null ? 'neutral' : v > 0 ? 'over' : 'under';
+
 const rows = computed(() =>
 	props.stages.map((stage) => {
 		const kpi =
@@ -31,16 +39,12 @@ const rows = computed(() =>
 			actualText: stage.actualDays != null ? stage.actualDays.toFixed(1) : '--',
 			status: stage.status,
 			statusLabel: STATUS_LABEL[stage.status],
-			varianceText:
-				stage.nodeVarianceDays == null
-					? '--'
-					: (stage.nodeVarianceDays > 0 ? '+' : '') + stage.nodeVarianceDays.toFixed(1),
-			varianceTone:
-				stage.nodeVarianceDays == null
-					? 'neutral'
-					: stage.nodeVarianceDays > 0
-						? 'over'
-						: 'under',
+			varianceText: fmtVar(stage.nodeVarianceDays),
+			varianceTone: tone(stage.nodeVarianceDays),
+			cumulativeText: fmtVar(stage.cumulativeVarianceDays),
+			cumulativeTone: tone(stage.cumulativeVarianceDays),
+			targetDate: stage.targetDate || '--',
+			actualReachedAt: stage.actualReachedAt ?? '尚未达成',
 			focused: !!props.focusKey && stage.key === props.focusKey,
 		};
 	}),
@@ -71,11 +75,24 @@ const rows = computed(() =>
 						<span class="oe-stage-cell__value">{{ row.actualText }}<span class="oe-stage-cell__unit"> 天</span></span>
 					</div>
 				</div>
+				<div class="oe-stage-cell__dates">
+					<span class="oe-stage-cell__date">
+						<span class="oe-stage-cell__date-label">计划</span>
+						<span class="oe-stage-cell__date-value">{{ row.targetDate }}</span>
+					</span>
+					<span class="oe-stage-cell__date">
+						<span class="oe-stage-cell__date-label">实达</span>
+						<span class="oe-stage-cell__date-value">{{ row.actualReachedAt }}</span>
+					</span>
+				</div>
 				<footer class="oe-stage-cell__footer">
 					<span class="oe-stage-cell__status">{{ row.statusLabel }}</span>
 					<span class="oe-stage-cell__variance" :class="`oe-stage-cell__variance--${row.varianceTone}`">
 						偏差 {{ row.varianceText }}
 					</span>
+					<span class="oe-stage-cell__variance" :class="`oe-stage-cell__variance--${row.cumulativeTone}`">
+						累计 {{ row.cumulativeText }}
+					</span>
 				</footer>
 			</div>
 		</div>
@@ -171,10 +188,35 @@ const rows = computed(() =>
 	font-weight: 400;
 }
 
+.oe-stage-cell__dates {
+	display: flex;
+	flex-direction: column;
+	gap: 3px;
+	font-size: 11px;
+	color: var(--order-text-secondary, #c6c6cd);
+}
+
+.oe-stage-cell__date {
+	display: flex;
+	gap: 6px;
+	align-items: baseline;
+}
+
+.oe-stage-cell__date-label {
+	color: var(--order-text-muted, #909097);
+	min-width: 28px;
+}
+
+.oe-stage-cell__date-value {
+	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
+}
+
 .oe-stage-cell__footer {
 	display: flex;
+	flex-wrap: wrap;
 	justify-content: space-between;
 	align-items: center;
+	gap: 6px;
 	font-size: 11px;
 }