|
|
@@ -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>
|