Kaynağa Gözat

feat(s8): add filters to order chain overview

YY968XX 2 ay önce
ebeveyn
işleme
b3c4da7357

+ 153 - 34
Web/src/views/aidop/s8/monitoring/OrderChainOverviewPage.vue

@@ -1,15 +1,29 @@
 <script setup lang="ts" name="aidopS8OrderExecutionChain">
 // ORDER-FLOW-CHAIN-SHELL-1:订单链路全景页壳。
+// ORDER-FLOW-CHAIN-FILTER-STAGE-1:6 维多选筛选 + 关键环节默认全选 + 全图矩阵 + 点击列切换详情焦点。
 // 入口:总览页订单卡「链路全景」按钮 → store.selectOrderForChain + router.push。
 // 刷新恢复:onMounted 调 store.restoreChainSelection() 从 sessionStorage 重建 selectedOrderNo。
-import { computed, onMounted } from 'vue';
+import { computed, onMounted, ref, watch } from 'vue';
 import { useRouter } from 'vue-router';
 import { useOrderExecutionStore } from '/@/stores/orderExecution';
-import StageMatrixCard from './components/order-execution/StageMatrixCard.vue';
+import type {
+	OrderNodeKey,
+	SalesOrderExecution,
+} from '/@/views/aidop/s8/monitoring/data/order-execution/types';
+import ChainStageFilterBar from './components/order-execution/ChainStageFilterBar.vue';
+import ChainOverviewList from './components/order-execution/ChainOverviewList.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 ALL_STAGE_KEYS: OrderNodeKey[] = [
+	'order_review',
+	'product_design',
+	'material_procurement',
+	'body_production',
+	'final_assembly_shipping',
+];
+
 const store = useOrderExecutionStore();
 const router = useRouter();
 
@@ -18,26 +32,55 @@ onMounted(() => {
 	store.restoreChainSelection();
 });
 
-const order = computed(() => store.selectedOrder);
-
-const focusStage = computed(() => {
-	const o = order.value;
-	if (!o) return null;
-	const lifecycle = o.lifecycle ?? [];
-	if (lifecycle.length === 0) return null;
-	return (
-		lifecycle.find((s) => s.key === o.focusNodeKey) ??
-		lifecycle.find((s) => s.key === o.currentNodeKey) ??
-		lifecycle[0] ??
-		null
-	);
+const selectedOrder = computed(() => store.selectedOrder);
+const allOrders = computed(() => store.allOrders);
+
+const selectedOrderNos = ref<string[]>([]);
+const selectedCustomerTypes = ref<string[]>([]);
+const selectedRegions = ref<string[]>([]);
+const selectedProductLines = ref<string[]>([]);
+const selectedWorkflowStatuses = ref<string[]>([]);
+const selectedStageKeys = ref<OrderNodeKey[]>([]);
+const activeStageKey = ref<OrderNodeKey | null>(null);
+
+const filteredChainOrders = computed<SalesOrderExecution[]>(() => {
+	const orderNoSet = new Set(selectedOrderNos.value);
+	const ctSet = new Set(selectedCustomerTypes.value);
+	const regionSet = new Set(selectedRegions.value);
+	const plSet = new Set(selectedProductLines.value);
+	const wfSet = new Set(selectedWorkflowStatuses.value);
+	return allOrders.value.filter((o) => {
+		if (orderNoSet.size > 0 && !orderNoSet.has(o.soNo)) return false;
+		if (ctSet.size > 0 && !ctSet.has(o.customerType)) return false;
+		if (regionSet.size > 0 && !regionSet.has(o.region)) return false;
+		if (plSet.size > 0 && !plSet.has(o.productLine)) return false;
+		if (wfSet.size > 0 && !wfSet.has(o.workflowStatus)) return false;
+		return true;
+	});
+});
+
+const visibleStageKeys = computed<OrderNodeKey[]>(() => {
+	if (selectedStageKeys.value.length === 0) return [...ALL_STAGE_KEYS];
+	const set = new Set(selectedStageKeys.value);
+	return ALL_STAGE_KEYS.filter((k) => set.has(k));
+});
+
+const detailOrder = computed<SalesOrderExecution | null>(() => {
+	if (selectedOrder.value && filteredChainOrders.value.some((o) => o.soNo === selectedOrder.value!.soNo)) {
+		return selectedOrder.value;
+	}
+	return filteredChainOrders.value[0] ?? selectedOrder.value ?? null;
 });
 
-const stages = computed(() => order.value?.lifecycle ?? []);
+const activeStage = computed(() => {
+	const o = detailOrder.value;
+	const key = activeStageKey.value;
+	if (!o || !key) return null;
+	return o.lifecycle.find((s) => s.key === key) ?? null;
+});
 
-// 评审子节点:order_review 阶段(无论 focus 是否在它)的 subSteps;用于稳定展示。
-const reviewStage = computed(() =>
-	(order.value?.lifecycle ?? []).find((s) => s.key === 'order_review') ?? null,
+const reviewStage = computed(
+	() => detailOrder.value?.lifecycle.find((s) => s.key === 'order_review') ?? null,
 );
 const reviewSubSteps = computed(() => reviewStage.value?.subSteps ?? null);
 const opinionSubStep = computed(
@@ -45,8 +88,43 @@ const opinionSubStep = computed(
 );
 const opinionBreakdown = computed(() => opinionSubStep.value?.breakdown ?? null);
 
-const showReviewSubSteps = computed(() => (reviewSubSteps.value?.length ?? 0) > 0);
-const showBreakdown = computed(() => (opinionBreakdown.value?.length ?? 0) > 0);
+const showReviewSubSteps = computed(
+	() => activeStageKey.value === 'order_review' && (reviewSubSteps.value?.length ?? 0) > 0,
+);
+const showBreakdown = computed(
+	() => activeStageKey.value === 'order_review' && (opinionBreakdown.value?.length ?? 0) > 0,
+);
+
+watch(
+	[detailOrder, visibleStageKeys],
+	([order, visible]) => {
+		if (!order) {
+			activeStageKey.value = null;
+			return;
+		}
+		if (activeStageKey.value && visible.includes(activeStageKey.value)) return;
+		const focus = (order.focusNodeKey ?? order.currentNodeKey) as OrderNodeKey | undefined;
+		if (focus && visible.includes(focus)) {
+			activeStageKey.value = focus;
+			return;
+		}
+		activeStageKey.value = visible[0] ?? null;
+	},
+	{ immediate: true },
+);
+
+function onStageClick(key: OrderNodeKey) {
+	activeStageKey.value = key;
+}
+
+function onResetFilters() {
+	selectedOrderNos.value = [];
+	selectedCustomerTypes.value = [];
+	selectedRegions.value = [];
+	selectedProductLines.value = [];
+	selectedWorkflowStatuses.value = [];
+	selectedStageKeys.value = [];
+}
 
 function goBack() {
 	if (window.history.length > 1) {
@@ -55,6 +133,18 @@ function goBack() {
 	}
 	router.push('/aidop/s8/monitoring/order-execution');
 }
+
+const STAGE_NAME_MAP: Record<OrderNodeKey, string> = {
+	order_review: '订单评审',
+	product_design: '产品设计',
+	material_procurement: '材料采购',
+	body_production: '本体生产',
+	final_assembly_shipping: '总装发货',
+};
+
+const activeStageName = computed(() =>
+	activeStageKey.value ? STAGE_NAME_MAP[activeStageKey.value] : '--',
+);
 </script>
 
 <template>
@@ -67,46 +157,74 @@ function goBack() {
 					<span class="oe-chain__breadcrumb-current">订单链路全景</span>
 				</p>
 				<h1 class="oe-chain__title-main">订单链路全景</h1>
-				<p v-if="order" class="oe-chain__title-sub">
-					<span class="oe-chain__so">{{ order.soNo }}</span>
-					<span class="oe-chain__sep">·</span>
-					<span>{{ order.productName }}</span>
+				<p class="oe-chain__title-sub">
+					<template v-if="detailOrder">
+						<span class="oe-chain__so">{{ detailOrder.soNo }}</span>
+						<span class="oe-chain__sep">·</span>
+						<span>{{ detailOrder.productName }}</span>
+						<span class="oe-chain__sep">·</span>
+						<span>当前环节 {{ activeStageName }}</span>
+					</template>
+					<template v-else>尚未选择订单</template>
 				</p>
-				<p v-else class="oe-chain__title-sub">尚未选择订单</p>
 			</div>
 			<el-button class="oe-chain__back" @click="goBack">‹ 返回总览</el-button>
 		</header>
 
-		<template v-if="order">
+		<ChainStageFilterBar
+			:orders="allOrders"
+			:hit-count="filteredChainOrders.length"
+			:total-count="allOrders.length"
+			:selected-order-nos="selectedOrderNos"
+			:selected-customer-types="selectedCustomerTypes"
+			:selected-regions="selectedRegions"
+			:selected-product-lines="selectedProductLines"
+			:selected-workflow-statuses="selectedWorkflowStatuses"
+			:selected-stage-keys="selectedStageKeys"
+			@update:selected-order-nos="selectedOrderNos = $event"
+			@update:selected-customer-types="selectedCustomerTypes = $event"
+			@update:selected-regions="selectedRegions = $event"
+			@update:selected-product-lines="selectedProductLines = $event"
+			@update:selected-workflow-statuses="selectedWorkflowStatuses = $event"
+			@update:selected-stage-keys="selectedStageKeys = $event"
+			@reset="onResetFilters"
+		/>
+
+		<template v-if="detailOrder">
 			<section class="oe-chain__summary">
 				<div class="oe-chain__summary-cell">
 					<span class="oe-chain__summary-label">客户</span>
-					<span class="oe-chain__summary-value">{{ order.customerName }}</span>
+					<span class="oe-chain__summary-value">{{ detailOrder.customerName }}</span>
 				</div>
 				<div class="oe-chain__summary-cell">
 					<span class="oe-chain__summary-label">产品</span>
-					<span class="oe-chain__summary-value">{{ order.productName }}</span>
+					<span class="oe-chain__summary-value">{{ detailOrder.productName }}</span>
 				</div>
 				<div class="oe-chain__summary-cell">
 					<span class="oe-chain__summary-label">当前节点</span>
-					<span class="oe-chain__summary-value">{{ order.currentNode }}</span>
+					<span class="oe-chain__summary-value">{{ detailOrder.currentNode }}</span>
 				</div>
 				<div class="oe-chain__summary-cell">
 					<span class="oe-chain__summary-label">异常次数</span>
 					<span class="oe-chain__summary-value oe-chain__summary-value--accent">
-						{{ order.exceptionCount }}
+						{{ detailOrder.exceptionCount }}
 					</span>
 				</div>
 				<div class="oe-chain__summary-cell">
 					<span class="oe-chain__summary-label">工作流</span>
 					<span class="oe-chain__summary-value">
-						{{ order.workflowStatus === 'in_progress' ? '执行中' : '已完成' }}
+						{{ detailOrder.workflowStatus === 'in_progress' ? '执行中' : '已完成' }}
 					</span>
 				</div>
 			</section>
 
-			<StageMatrixCard :stages="stages" :focus-key="focusStage?.key ?? null" />
-			<NodeDetailCard :stage="focusStage" />
+			<ChainOverviewList
+				:orders="filteredChainOrders"
+				:selected-stage-keys="selectedStageKeys"
+				:active-stage-key="activeStageKey"
+				@stage-click="onStageClick"
+			/>
+			<NodeDetailCard :stage="activeStage" />
 			<ReviewSubStepsCard
 				v-if="showReviewSubSteps"
 				:sub-steps="reviewSubSteps"
@@ -219,6 +337,7 @@ function goBack() {
 	display: flex;
 	align-items: center;
 	gap: 8px;
+	flex-wrap: wrap;
 }
 
 .oe-chain__so {

+ 380 - 0
Web/src/views/aidop/s8/monitoring/components/order-execution/ChainOverviewList.vue

@@ -0,0 +1,380 @@
+<script setup lang="ts" name="ChainOverviewList">
+// ORDER-FLOW-CHAIN-FILTER-STAGE-1:链路全景关键环节矩阵。
+// 5 列固定阶段,selectedStageKeys 空 = 全部 5 列;多订单时取均值聚合。
+import { computed } from 'vue';
+import type {
+	OrderNodeKey,
+	SalesOrderExecution,
+	StageSnapshot,
+} from '/@/views/aidop/s8/monitoring/data/order-execution/types';
+
+interface Props {
+	orders: SalesOrderExecution[];
+	selectedStageKeys: OrderNodeKey[];
+	activeStageKey: OrderNodeKey | null;
+}
+
+const props = defineProps<Props>();
+
+const emit = defineEmits<{
+	'stage-click': [key: OrderNodeKey];
+}>();
+
+const STAGE_ORDER: ReadonlyArray<{ key: OrderNodeKey; name: string; ownerDept: string }> = [
+	{ key: 'order_review', name: '订单评审', ownerDept: '市场部' },
+	{ key: 'product_design', name: '产品设计', ownerDept: '研发中心' },
+	{ key: 'material_procurement', name: '材料采购', ownerDept: '供应链部' },
+	{ key: 'body_production', name: '本体生产', ownerDept: '生产部' },
+	{ key: 'final_assembly_shipping', name: '总装发货', ownerDept: '生产部' },
+];
+
+interface ColumnAgg {
+	key: OrderNodeKey;
+	name: string;
+	ownerDept: string;
+	kpiText: string;
+	actualText: string;
+	statusText: string;
+	statusTone: 'green' | 'yellow' | 'red' | 'pending' | 'mixed';
+}
+
+function pickStage(order: SalesOrderExecution, key: OrderNodeKey): StageSnapshot | undefined {
+	return order.lifecycle.find((s) => s.key === key);
+}
+
+function avg(values: number[]): number | null {
+	if (values.length === 0) return null;
+	const sum = values.reduce((a, b) => a + b, 0);
+	return sum / values.length;
+}
+
+function aggregateColumn(
+	key: OrderNodeKey,
+	name: string,
+	ownerDept: string,
+	orders: SalesOrderExecution[],
+): ColumnAgg {
+	const stages = orders.map((o) => pickStage(o, key)).filter((s): s is StageSnapshot => !!s);
+
+	if (stages.length === 0) {
+		return {
+			key,
+			name,
+			ownerDept,
+			kpiText: '--',
+			actualText: '--',
+			statusText: '暂无',
+			statusTone: 'pending',
+		};
+	}
+
+	const kpiVals: number[] = [];
+	const actualVals: number[] = [];
+	for (const s of stages) {
+		if (s.actualDays != null && s.nodeVarianceDays != null) {
+			kpiVals.push(Number((s.actualDays - s.nodeVarianceDays).toFixed(2)));
+		}
+		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 };
+	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: '未开始',
+		};
+		statusTone = stages[0].status;
+		statusText = labels[stages[0].status];
+	} 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';
+	}
+
+	return {
+		key,
+		name,
+		ownerDept,
+		kpiText: kpiAvg != null ? `${kpiAvg.toFixed(1)} 天` : '--',
+		actualText: actualAvg != null ? `${actualAvg.toFixed(1)} 天` : '--',
+		statusText,
+		statusTone,
+	};
+}
+
+const visibleStages = computed(() => {
+	const filterSet = new Set(props.selectedStageKeys);
+	const useAll = props.selectedStageKeys.length === 0;
+	return STAGE_ORDER.filter((s) => useAll || filterSet.has(s.key));
+});
+
+const columns = computed<ColumnAgg[]>(() =>
+	visibleStages.value.map((s) => aggregateColumn(s.key, s.name, s.ownerDept, props.orders)),
+);
+
+const sampleSoNo = computed(() =>
+	props.orders.length === 1 ? props.orders[0].soNo : null,
+);
+
+function onClick(key: OrderNodeKey) {
+	emit('stage-click', key);
+}
+</script>
+
+<template>
+	<section class="chain-matrix">
+		<header class="chain-matrix__head">
+			<span class="chain-matrix__bar" />
+			<h2 class="chain-matrix__title">关键环节进度矩阵</h2>
+			<span v-if="sampleSoNo" class="chain-matrix__sample">当前订单 {{ sampleSoNo }}</span>
+			<span v-else-if="orders.length > 0" class="chain-matrix__sample">
+				多订单聚合 · 样本 {{ orders.length }}
+			</span>
+		</header>
+		<div v-if="orders.length === 0" class="chain-matrix__empty">暂无匹配订单</div>
+		<div v-else-if="columns.length === 0" class="chain-matrix__empty">暂无关键环节</div>
+		<table v-else class="chain-matrix__table">
+			<thead>
+				<tr>
+					<th class="chain-matrix__row-head">关键环节</th>
+					<th
+						v-for="col in columns"
+						:key="col.key"
+						class="chain-matrix__col-head"
+						:class="{
+							'chain-matrix__col-head--active': activeStageKey === col.key,
+							[`chain-matrix__col-head--${col.statusTone}`]: true,
+						}"
+						role="button"
+						tabindex="0"
+						@click="onClick(col.key)"
+						@keydown.enter.prevent="onClick(col.key)"
+						@keydown.space.prevent="onClick(col.key)"
+					>
+						{{ col.name }}
+						<span v-if="activeStageKey === col.key" class="chain-matrix__active-pin">当前</span>
+					</th>
+				</tr>
+			</thead>
+			<tbody>
+				<tr>
+					<td class="chain-matrix__row-label">负责部门</td>
+					<td
+						v-for="col in columns"
+						:key="`d-${col.key}`"
+						:class="{ 'chain-matrix__cell--active': activeStageKey === col.key }"
+					>
+						{{ col.ownerDept }}
+					</td>
+				</tr>
+				<tr>
+					<td class="chain-matrix__row-label">KPI</td>
+					<td
+						v-for="col in columns"
+						:key="`k-${col.key}`"
+						:class="{ 'chain-matrix__cell--active': activeStageKey === col.key }"
+					>
+						{{ col.kpiText }}
+					</td>
+				</tr>
+				<tr>
+					<td class="chain-matrix__row-label">实际达成</td>
+					<td
+						v-for="col in columns"
+						:key="`a-${col.key}`"
+						:class="{ 'chain-matrix__cell--active': activeStageKey === col.key }"
+					>
+						{{ col.actualText }}
+					</td>
+				</tr>
+				<tr>
+					<td class="chain-matrix__row-label">达标情况</td>
+					<td
+						v-for="col in columns"
+						:key="`s-${col.key}`"
+						:class="{ 'chain-matrix__cell--active': activeStageKey === col.key }"
+					>
+						<span class="chain-matrix__status" :class="`chain-matrix__status--${col.statusTone}`">
+							<span class="chain-matrix__status-dot" />
+							{{ col.statusText }}
+						</span>
+					</td>
+				</tr>
+			</tbody>
+		</table>
+	</section>
+</template>
+
+<style scoped>
+.chain-matrix {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+}
+
+.chain-matrix__head {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+}
+
+.chain-matrix__bar {
+	width: 5px;
+	height: 18px;
+	border-radius: 999px;
+	background: var(--order-accent, #7bd0ff);
+}
+
+.chain-matrix__title {
+	margin: 0;
+	font-size: 16px;
+	font-weight: 700;
+}
+
+.chain-matrix__sample {
+	margin-left: 8px;
+	padding: 2px 10px;
+	border-radius: 999px;
+	font-size: 11px;
+	color: var(--order-accent, #7bd0ff);
+	background: rgba(123, 208, 255, 0.1);
+	border: 1px solid rgba(123, 208, 255, 0.24);
+}
+
+.chain-matrix__empty {
+	padding: 20px 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;
+}
+
+.chain-matrix__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;
+	table-layout: fixed;
+}
+
+.chain-matrix__table th,
+.chain-matrix__table td {
+	padding: 10px;
+	text-align: center;
+	border-bottom: 1px solid rgba(69, 70, 77, 0.18);
+	border-right: 1px solid rgba(69, 70, 77, 0.18);
+	color: var(--order-text-primary, #e1e2eb);
+	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
+}
+
+.chain-matrix__table th:last-child,
+.chain-matrix__table td:last-child {
+	border-right: none;
+}
+
+.chain-matrix__table tr:last-child td {
+	border-bottom: none;
+}
+
+.chain-matrix__row-head,
+.chain-matrix__row-label {
+	width: 140px;
+	text-align: left;
+	color: var(--order-text-muted, #909097);
+	background: rgba(15, 19, 26, 0.55);
+	font-family: 'PingFang SC', sans-serif;
+	font-weight: 600;
+}
+
+.chain-matrix__col-head {
+	cursor: pointer;
+	user-select: none;
+	background: rgba(15, 19, 26, 0.7);
+	color: var(--order-text-primary, #e1e2eb);
+	font-weight: 700;
+	font-family: 'PingFang SC', sans-serif;
+	transition: background 120ms ease, color 120ms ease, box-shadow 120ms ease;
+}
+
+.chain-matrix__col-head:hover {
+	background: rgba(123, 208, 255, 0.08);
+	color: #7bd0ff;
+}
+
+.chain-matrix__col-head:focus-visible {
+	outline: 2px solid rgba(123, 208, 255, 0.5);
+	outline-offset: -2px;
+}
+
+.chain-matrix__col-head--green { box-shadow: inset 0 3px 0 #88fd54; }
+.chain-matrix__col-head--yellow { box-shadow: inset 0 3px 0 #ffc107; }
+.chain-matrix__col-head--red { box-shadow: inset 0 3px 0 #ffb4ab; }
+.chain-matrix__col-head--mixed { box-shadow: inset 0 3px 0 rgba(123, 208, 255, 0.5); }
+.chain-matrix__col-head--pending { box-shadow: inset 0 3px 0 rgba(144, 144, 151, 0.45); }
+
+.chain-matrix__col-head--active {
+	background: rgba(123, 208, 255, 0.16);
+	color: #ffffff;
+	box-shadow: inset 0 0 0 1px rgba(123, 208, 255, 0.7);
+}
+
+.chain-matrix__active-pin {
+	margin-left: 6px;
+	padding: 1px 6px;
+	border-radius: 999px;
+	font-size: 10px;
+	letter-spacing: 0.06em;
+	color: #10131a;
+	background: #7bd0ff;
+}
+
+.chain-matrix__cell--active {
+	background: rgba(123, 208, 255, 0.06);
+}
+
+.chain-matrix__status {
+	display: inline-flex;
+	align-items: center;
+	gap: 5px;
+	padding: 2px 10px;
+	border-radius: 999px;
+	font-size: 11px;
+	font-family: 'PingFang SC', sans-serif;
+	background: rgba(50, 53, 60, 0.6);
+}
+
+.chain-matrix__status-dot {
+	width: 6px;
+	height: 6px;
+	border-radius: 999px;
+	background: currentColor;
+}
+
+.chain-matrix__status--green { color: #88fd54; }
+.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); }
+</style>

+ 304 - 0
Web/src/views/aidop/s8/monitoring/components/order-execution/ChainStageFilterBar.vue

@@ -0,0 +1,304 @@
+<script setup lang="ts" name="ChainStageFilterBar">
+// ORDER-FLOW-CHAIN-FILTER-STAGE-1:链路全景页筛选栏。
+// 6 个多选维度:订单号 / 客户类型 / 区域 / 产品线 / 订单进度 / 关键环节。
+// 默认全选语义:未选 = 包含全部;不允许 0 命中。
+import { computed } from 'vue';
+import type {
+	OrderNodeKey,
+	SalesOrderExecution,
+} from '/@/views/aidop/s8/monitoring/data/order-execution/types';
+
+interface Props {
+	orders: SalesOrderExecution[];
+	hitCount: number;
+	totalCount: number;
+	selectedOrderNos: string[];
+	selectedCustomerTypes: string[];
+	selectedRegions: string[];
+	selectedProductLines: string[];
+	selectedWorkflowStatuses: string[];
+	selectedStageKeys: OrderNodeKey[];
+}
+
+const props = defineProps<Props>();
+
+const emit = defineEmits<{
+	'update:selectedOrderNos': [value: string[]];
+	'update:selectedCustomerTypes': [value: string[]];
+	'update:selectedRegions': [value: string[]];
+	'update:selectedProductLines': [value: string[]];
+	'update:selectedWorkflowStatuses': [value: string[]];
+	'update:selectedStageKeys': [value: OrderNodeKey[]];
+	reset: [];
+}>();
+
+const STAGE_OPTIONS: ReadonlyArray<{ value: OrderNodeKey; label: string }> = [
+	{ value: 'order_review', label: '订单评审' },
+	{ value: 'product_design', label: '产品设计' },
+	{ value: 'material_procurement', label: '材料采购' },
+	{ value: 'body_production', label: '本体生产' },
+	{ value: 'final_assembly_shipping', label: '总装发货' },
+];
+
+const WORKFLOW_LABEL: Record<string, string> = {
+	completed: '已完成',
+	in_progress: '进行中',
+};
+
+function dedupe<T>(arr: T[]): T[] {
+	return Array.from(new Set(arr));
+}
+
+const orderNoOptions = computed(() =>
+	dedupe(props.orders.map((o) => o.soNo)).map((v) => ({ value: v, label: v })),
+);
+const customerTypeOptions = computed(() =>
+	dedupe(props.orders.map((o) => o.customerType)).map((v) => ({ value: v, label: v })),
+);
+const regionOptions = computed(() =>
+	dedupe(props.orders.map((o) => o.region).filter(Boolean)).map((v) => ({ value: v, label: v })),
+);
+const productLineOptions = computed(() =>
+	dedupe(props.orders.map((o) => o.productLine).filter(Boolean)).map((v) => ({
+		value: v,
+		label: v,
+	})),
+);
+const workflowOptions = computed(() =>
+	dedupe(props.orders.map((o) => o.workflowStatus)).map((v) => ({
+		value: v,
+		label: WORKFLOW_LABEL[v] ?? v,
+	})),
+);
+
+const stageDisplay = computed(() =>
+	props.selectedStageKeys.length === 0 ? '默认全选' : `已选 ${props.selectedStageKeys.length} / 5`,
+);
+</script>
+
+<template>
+	<div class="chain-filters">
+		<div class="chain-filters__head">
+			<span class="chain-filters__tag">筛选</span>
+			<span class="chain-filters__count">
+				当前命中订单
+				<strong>{{ hitCount }}</strong>
+				<span class="chain-filters__count-sep">/</span>
+				全部
+				<strong>{{ totalCount }}</strong>
+			</span>
+			<span class="chain-filters__count chain-filters__count--stage">
+				关键环节:{{ stageDisplay }}
+			</span>
+			<el-button class="chain-filters__reset" @click="emit('reset')">重置筛选</el-button>
+		</div>
+		<div class="chain-filters__row">
+			<el-select
+				:model-value="selectedOrderNos"
+				class="chain-filters__select chain-filters__select--wide"
+				multiple
+				filterable
+				clearable
+				collapse-tags
+				collapse-tags-tooltip
+				placeholder="订单号 (默认全部)"
+				@update:model-value="(v: string[]) => emit('update:selectedOrderNos', v)"
+			>
+				<el-option
+					v-for="opt in orderNoOptions"
+					:key="`o-${opt.value}`"
+					:value="opt.value"
+					:label="opt.label"
+				/>
+			</el-select>
+			<el-select
+				:model-value="selectedCustomerTypes"
+				class="chain-filters__select"
+				multiple
+				clearable
+				collapse-tags
+				collapse-tags-tooltip
+				placeholder="客户类型 (默认全部)"
+				@update:model-value="(v: string[]) => emit('update:selectedCustomerTypes', v)"
+			>
+				<el-option
+					v-for="opt in customerTypeOptions"
+					:key="`ct-${opt.value}`"
+					:value="opt.value"
+					:label="opt.label"
+				/>
+			</el-select>
+			<el-select
+				:model-value="selectedRegions"
+				class="chain-filters__select"
+				multiple
+				clearable
+				collapse-tags
+				collapse-tags-tooltip
+				placeholder="区域 (默认全部)"
+				@update:model-value="(v: string[]) => emit('update:selectedRegions', v)"
+			>
+				<el-option
+					v-for="opt in regionOptions"
+					:key="`r-${opt.value}`"
+					:value="opt.value"
+					:label="opt.label"
+				/>
+			</el-select>
+			<el-select
+				:model-value="selectedProductLines"
+				class="chain-filters__select"
+				multiple
+				clearable
+				collapse-tags
+				collapse-tags-tooltip
+				placeholder="产品线 (默认全部)"
+				@update:model-value="(v: string[]) => emit('update:selectedProductLines', v)"
+			>
+				<el-option
+					v-for="opt in productLineOptions"
+					:key="`p-${opt.value}`"
+					:value="opt.value"
+					:label="opt.label"
+				/>
+			</el-select>
+			<el-select
+				:model-value="selectedWorkflowStatuses"
+				class="chain-filters__select"
+				multiple
+				clearable
+				collapse-tags
+				collapse-tags-tooltip
+				placeholder="订单进度 (默认全部)"
+				@update:model-value="(v: string[]) => emit('update:selectedWorkflowStatuses', v)"
+			>
+				<el-option
+					v-for="opt in workflowOptions"
+					:key="`w-${opt.value}`"
+					:value="opt.value"
+					:label="opt.label"
+				/>
+			</el-select>
+			<el-select
+				:model-value="selectedStageKeys"
+				class="chain-filters__select chain-filters__select--stage"
+				multiple
+				clearable
+				collapse-tags
+				collapse-tags-tooltip
+				placeholder="关键环节 (默认全选 5)"
+				@update:model-value="(v: OrderNodeKey[]) => emit('update:selectedStageKeys', v)"
+			>
+				<el-option
+					v-for="opt in STAGE_OPTIONS"
+					:key="`s-${opt.value}`"
+					:value="opt.value"
+					:label="opt.label"
+				/>
+			</el-select>
+		</div>
+	</div>
+</template>
+
+<style scoped>
+.chain-filters {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+	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));
+}
+
+.chain-filters__head {
+	display: flex;
+	align-items: center;
+	flex-wrap: wrap;
+	gap: 12px;
+}
+
+.chain-filters__tag {
+	padding: 3px 10px;
+	border-radius: 999px;
+	font-size: 11px;
+	font-weight: 700;
+	letter-spacing: 0.08em;
+	color: #7bd0ff;
+	background: rgba(123, 208, 255, 0.1);
+	border: 1px solid rgba(123, 208, 255, 0.28);
+	flex: 0 0 auto;
+}
+
+.chain-filters__count {
+	font-size: 12px;
+	color: var(--order-text-secondary, #c6c6cd);
+	display: inline-flex;
+	align-items: baseline;
+	gap: 4px;
+}
+
+.chain-filters__count strong {
+	color: var(--order-accent, #7bd0ff);
+	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
+	font-size: 13px;
+}
+
+.chain-filters__count-sep {
+	color: var(--order-text-muted, #909097);
+}
+
+.chain-filters__count--stage {
+	color: var(--order-text-muted, #909097);
+}
+
+.chain-filters__reset {
+	margin-left: auto;
+}
+
+:deep(.chain-filters__reset) {
+	border-color: rgba(123, 208, 255, 0.32);
+	color: #7bd0ff;
+	background: rgba(123, 208, 255, 0.06);
+}
+
+:deep(.chain-filters__reset:hover) {
+	background: rgba(123, 208, 255, 0.16);
+	color: #7bd0ff;
+	border-color: rgba(123, 208, 255, 0.5);
+}
+
+.chain-filters__row {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 10px;
+}
+
+.chain-filters__select {
+	width: 168px;
+	flex: 0 0 168px;
+}
+
+.chain-filters__select--wide {
+	width: 240px;
+	flex: 0 0 240px;
+}
+
+.chain-filters__select--stage {
+	width: 200px;
+	flex: 0 0 200px;
+}
+
+@media (max-width: 768px) {
+	.chain-filters__select,
+	.chain-filters__select--wide,
+	.chain-filters__select--stage {
+		width: 100%;
+		flex: 1 1 100%;
+	}
+
+	.chain-filters__reset {
+		margin-left: 0;
+	}
+}
+</style>