|
|
@@ -1,15 +1,105 @@
|
|
|
<script setup lang="ts" name="DesignDetailPanel">
|
|
|
-// ORDER-FLOW-CHAIN-STAGE-DETAIL-MIGRATE-1:产品设计阶段结构化详情。fixture 来源 ecc-sandbox 硬编码;非真实数据。
|
|
|
-import type { DesignDetail, StageDetailStatus } from '/@/views/aidop/s8/monitoring/data/order-execution/stage-detail';
|
|
|
+// S8-ORDER-CHAIN-PRODUCT-DESIGN-DRAWING-PERSIST-1:产品设计阶段图号粒度详情面板。
|
|
|
+// 数据源:ado_s8_order_flow_product_design_drawing → /api/aidop/s8/order-flow/product-design/drawings。
|
|
|
+// 分类汇总 7 列与图号执行明细 9 列 UI 结构保持稳定;activeCategory 控制明细过滤,三档点击即过滤。
|
|
|
+import { computed, ref, watch } from 'vue';
|
|
|
+import type {
|
|
|
+ ProductDesignDetailDto,
|
|
|
+ ProductDesignDrawingItemDto,
|
|
|
+ ProductDesignProductType,
|
|
|
+} from '/@/views/aidop/s8/monitoring/data/order-execution/types';
|
|
|
|
|
|
-defineProps<{ detail: DesignDetail }>();
|
|
|
+interface Props {
|
|
|
+ detail: ProductDesignDetailDto | null;
|
|
|
+ loading?: boolean;
|
|
|
+ errorMessage?: string | null;
|
|
|
+}
|
|
|
+
|
|
|
+const props = withDefaults(defineProps<Props>(), {
|
|
|
+ loading: false,
|
|
|
+ errorMessage: null,
|
|
|
+});
|
|
|
+
|
|
|
+const activeCategory = ref<ProductDesignProductType>('TOTAL');
|
|
|
+
|
|
|
+const categoryRows = computed(() => props.detail?.summary.categories ?? []);
|
|
|
+
|
|
|
+const drawings = computed<ProductDesignDrawingItemDto[]>(() => {
|
|
|
+ const all = props.detail?.drawings ?? [];
|
|
|
+ if (activeCategory.value === 'TOTAL') return all;
|
|
|
+ return all.filter((d) => d.productType === activeCategory.value);
|
|
|
+});
|
|
|
+
|
|
|
+const isEmpty = computed(
|
|
|
+ () => !props.loading && !props.errorMessage && (props.detail?.drawings.length ?? 0) === 0,
|
|
|
+);
|
|
|
+
|
|
|
+// detail 切换(筛选订单变化)时如果当前 activeCategory 在新 categories 中已不存在,
|
|
|
+// fallback 到 TOTAL,避免明细表恒空。
|
|
|
+watch(
|
|
|
+ () => props.detail,
|
|
|
+ (next) => {
|
|
|
+ if (!next) return;
|
|
|
+ const allowed = new Set(next.summary.categories.map((c) => c.productType));
|
|
|
+ if (!allowed.has(activeCategory.value)) activeCategory.value = 'TOTAL';
|
|
|
+ },
|
|
|
+);
|
|
|
+
|
|
|
+function formatRatio(value: number | null | undefined): string {
|
|
|
+ if (value == null || Number.isNaN(value)) return '—';
|
|
|
+ return `${(value * 100).toFixed(1)}%`;
|
|
|
+}
|
|
|
+
|
|
|
+function formatAchievementRate(value: number | null | undefined): string {
|
|
|
+ if (value == null || Number.isNaN(value)) return '—';
|
|
|
+ return `${Math.round(value * 100)}%`;
|
|
|
+}
|
|
|
|
|
|
-function statusColor(s: StageDetailStatus | null): string {
|
|
|
+function formatDays(value: number | null | undefined): string {
|
|
|
+ if (value == null || Number.isNaN(value)) return '—';
|
|
|
+ return `${value.toFixed(2)} 天`;
|
|
|
+}
|
|
|
+
|
|
|
+function formatKpiDays(value: number | null | undefined): string {
|
|
|
+ if (value == null || Number.isNaN(value)) return '—';
|
|
|
+ return `${value.toFixed(0)} 天`;
|
|
|
+}
|
|
|
+
|
|
|
+function formatInt(value: number | null | undefined): string {
|
|
|
+ if (value == null || Number.isNaN(value)) return '—';
|
|
|
+ return String(value);
|
|
|
+}
|
|
|
+
|
|
|
+function formatDate(iso: string | null | undefined): string {
|
|
|
+ if (!iso) return '—';
|
|
|
+ const d = new Date(iso);
|
|
|
+ if (Number.isNaN(d.getTime())) return '—';
|
|
|
+ const y = d.getFullYear();
|
|
|
+ const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
|
+ const day = String(d.getDate()).padStart(2, '0');
|
|
|
+ return `${y}-${m}-${day}`;
|
|
|
+}
|
|
|
+
|
|
|
+function categoryDisplay(productType: string): string {
|
|
|
+ if (productType === 'STANDARD') return '常规产品';
|
|
|
+ if (productType === 'NON_STANDARD') return '非标产品';
|
|
|
+ return productType;
|
|
|
+}
|
|
|
+
|
|
|
+function statusColor(s: string | null | undefined): string {
|
|
|
if (s === 'red') return '#ff4d4f';
|
|
|
if (s === 'yellow') return '#ffc107';
|
|
|
if (s === 'green') return '#88fd54';
|
|
|
return 'var(--order-text-muted, #909097)';
|
|
|
}
|
|
|
+
|
|
|
+function onSelectCategory(productType: string) {
|
|
|
+ activeCategory.value = productType as ProductDesignProductType;
|
|
|
+}
|
|
|
+
|
|
|
+function isCategoryActive(productType: string): boolean {
|
|
|
+ return activeCategory.value === productType;
|
|
|
+}
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
@@ -17,77 +107,187 @@ function statusColor(s: StageDetailStatus | null): string {
|
|
|
<header class="design-panel__head">
|
|
|
<span class="design-panel__bar" />
|
|
|
<h2 class="design-panel__title">产品设计 · 阶段详情</h2>
|
|
|
- <span class="design-panel__demo">演示数据</span>
|
|
|
</header>
|
|
|
|
|
|
- <div class="design-panel__block">
|
|
|
- <div class="design-panel__caption">设计分类汇总</div>
|
|
|
- <table class="design-panel__table">
|
|
|
- <thead>
|
|
|
- <tr>
|
|
|
- <th>产品类型</th><th>台数</th><th>占比</th><th>KPI</th><th>平均设计周期</th><th>达标</th><th>图号数量</th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <tbody>
|
|
|
- <tr v-for="row in detail.categoryRows" :key="row.type">
|
|
|
- <td>{{ row.type }}</td>
|
|
|
- <td>{{ row.count }}</td>
|
|
|
- <td>{{ row.ratio }}</td>
|
|
|
- <td>{{ row.kpi }}</td>
|
|
|
- <td :style="{ color: statusColor(row.status) }">{{ row.avgCycle }}</td>
|
|
|
- <td>
|
|
|
- <span v-if="row.status" class="design-panel__dot" :style="{ background: statusColor(row.status) }" />
|
|
|
- <span v-else>—</span>
|
|
|
- </td>
|
|
|
- <td>{{ row.drawingCount }}</td>
|
|
|
- </tr>
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
+ <div v-if="loading" class="design-panel__hint">产品设计图号数据加载中</div>
|
|
|
+ <div v-else-if="errorMessage" class="design-panel__hint design-panel__hint--error">
|
|
|
+ {{ errorMessage || '产品设计图号数据加载失败' }}
|
|
|
</div>
|
|
|
+ <div v-else-if="isEmpty" class="design-panel__hint">当前筛选范围暂无产品设计图号记录</div>
|
|
|
|
|
|
- <div class="design-panel__block">
|
|
|
- <div class="design-panel__caption">图号执行明细(含两类样例)</div>
|
|
|
- <table class="design-panel__table">
|
|
|
- <thead>
|
|
|
- <tr>
|
|
|
- <th>负责人</th><th>图号</th><th>类别</th><th>计划开始</th><th>计划结束</th><th>实际开始</th><th>实际结束</th><th>设计周期</th><th>达标</th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <tbody>
|
|
|
- <tr v-for="row in detail.drawings" :key="row.id">
|
|
|
- <td>{{ row.owner }}</td>
|
|
|
- <td>{{ row.id }}</td>
|
|
|
- <td>{{ row.category }}</td>
|
|
|
- <td>{{ row.planStart }}</td>
|
|
|
- <td>{{ row.planEnd }}</td>
|
|
|
- <td>{{ row.actualStart }}</td>
|
|
|
- <td>{{ row.actualEnd }}</td>
|
|
|
- <td :style="{ color: statusColor(row.status) }">{{ row.cycle }}</td>
|
|
|
- <td><span class="design-panel__dot" :style="{ background: statusColor(row.status) }" /></td>
|
|
|
- </tr>
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
- </div>
|
|
|
+ <template v-else-if="detail">
|
|
|
+ <div class="design-panel__block">
|
|
|
+ <div class="design-panel__caption">设计分类汇总(点击行可过滤下方图号执行明细)</div>
|
|
|
+ <table class="design-panel__table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>产品类型</th>
|
|
|
+ <th>台数</th>
|
|
|
+ <th>占比</th>
|
|
|
+ <th>KPI</th>
|
|
|
+ <th>平均设计周期</th>
|
|
|
+ <th>达标</th>
|
|
|
+ <th>图号数量</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ <tr
|
|
|
+ v-for="row in categoryRows"
|
|
|
+ :key="row.productType"
|
|
|
+ class="design-panel__row"
|
|
|
+ :class="{ 'design-panel__row--active': isCategoryActive(row.productType) }"
|
|
|
+ role="button"
|
|
|
+ tabindex="0"
|
|
|
+ @click="onSelectCategory(row.productType)"
|
|
|
+ @keydown.enter.prevent="onSelectCategory(row.productType)"
|
|
|
+ @keydown.space.prevent="onSelectCategory(row.productType)"
|
|
|
+ >
|
|
|
+ <td>{{ row.name }}</td>
|
|
|
+ <td>{{ formatInt(row.totalQuantity) }}</td>
|
|
|
+ <td>{{ formatRatio(row.ratio) }}</td>
|
|
|
+ <td>{{ formatKpiDays(row.kpiDays) }}</td>
|
|
|
+ <td :style="{ color: statusColor(row.status) }">{{ formatDays(row.avgActualDays) }}</td>
|
|
|
+ <td>
|
|
|
+ <template v-if="row.status">
|
|
|
+ <span class="design-panel__dot" :style="{ background: statusColor(row.status) }" />
|
|
|
+ <span class="design-panel__rate">{{ formatAchievementRate(row.achievementRate) }}</span>
|
|
|
+ </template>
|
|
|
+ <span v-else>—</span>
|
|
|
+ </td>
|
|
|
+ <td>{{ formatInt(row.drawingCount) }}</td>
|
|
|
+ </tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="design-panel__block">
|
|
|
+ <div class="design-panel__caption">图号执行明细</div>
|
|
|
+ <table class="design-panel__table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>负责人</th>
|
|
|
+ <th>图号</th>
|
|
|
+ <th>类别</th>
|
|
|
+ <th>计划开始</th>
|
|
|
+ <th>计划结束</th>
|
|
|
+ <th>实际开始</th>
|
|
|
+ <th>实际结束</th>
|
|
|
+ <th>设计周期</th>
|
|
|
+ <th>达标</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ <tr v-for="row in drawings" :key="row.drawingNo">
|
|
|
+ <td>{{ row.responsiblePerson }}</td>
|
|
|
+ <td>{{ row.drawingNo }}</td>
|
|
|
+ <td>{{ categoryDisplay(row.productType) }}</td>
|
|
|
+ <td>{{ formatDate(row.plannedStartDate) }}</td>
|
|
|
+ <td>{{ formatDate(row.plannedEndDate) }}</td>
|
|
|
+ <td>{{ formatDate(row.actualStartDate) }}</td>
|
|
|
+ <td>{{ formatDate(row.actualEndDate) }}</td>
|
|
|
+ <td :style="{ color: statusColor(row.status) }">{{ formatDays(row.actualDays) }}</td>
|
|
|
+ <td><span class="design-panel__dot" :style="{ background: statusColor(row.status) }" /></td>
|
|
|
+ </tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
</section>
|
|
|
</template>
|
|
|
|
|
|
<style scoped>
|
|
|
-.design-panel { display: flex; flex-direction: column; gap: 12px; }
|
|
|
-.design-panel__head { display: flex; align-items: center; gap: 10px; }
|
|
|
-.design-panel__bar { width: 5px; height: 18px; border-radius: 999px; background: var(--order-accent, #7bd0ff); }
|
|
|
-.design-panel__title { margin: 0; font-size: 16px; font-weight: 700; }
|
|
|
-.design-panel__demo {
|
|
|
- margin-left: 6px; padding: 2px 10px; border-radius: 999px; font-size: 11px;
|
|
|
- color: #ffc107; background: rgba(255,193,7,0.1); border: 1px solid rgba(255,193,7,0.32);
|
|
|
-}
|
|
|
-.design-panel__block { background: rgba(25, 28, 34, 0.55); border: 1px solid rgba(69, 70, 77, 0.28); border-radius: 10px; padding: 12px; overflow-x: auto; }
|
|
|
-.design-panel__caption { font-size: 12px; color: var(--order-text-muted, #909097); margin-bottom: 8px; }
|
|
|
-.design-panel__table { width: 100%; border-collapse: collapse; font-size: 12px; color: var(--order-text-primary, #e1e2eb); }
|
|
|
-.design-panel__table th, .design-panel__table td {
|
|
|
- padding: 8px 10px; text-align: center;
|
|
|
+.design-panel {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+.design-panel__head {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+.design-panel__bar {
|
|
|
+ width: 5px;
|
|
|
+ height: 18px;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: var(--order-accent, #7bd0ff);
|
|
|
+}
|
|
|
+.design-panel__title {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+.design-panel__hint {
|
|
|
+ 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;
|
|
|
+}
|
|
|
+.design-panel__hint--error {
|
|
|
+ color: #ff8a8a;
|
|
|
+ border-style: solid;
|
|
|
+ border-color: rgba(255, 77, 79, 0.32);
|
|
|
+ background: rgba(255, 77, 79, 0.08);
|
|
|
+}
|
|
|
+.design-panel__block {
|
|
|
+ background: rgba(25, 28, 34, 0.55);
|
|
|
+ border: 1px solid rgba(69, 70, 77, 0.28);
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 12px;
|
|
|
+ overflow-x: auto;
|
|
|
+}
|
|
|
+.design-panel__caption {
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--order-text-muted, #909097);
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+.design-panel__table {
|
|
|
+ width: 100%;
|
|
|
+ border-collapse: collapse;
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--order-text-primary, #e1e2eb);
|
|
|
+}
|
|
|
+.design-panel__table th,
|
|
|
+.design-panel__table td {
|
|
|
+ padding: 8px 10px;
|
|
|
+ text-align: center;
|
|
|
border: 1px solid rgba(69, 70, 77, 0.18);
|
|
|
font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
|
|
|
}
|
|
|
-.design-panel__table th { background: rgba(15, 19, 26, 0.7); font-weight: 600; font-family: 'PingFang SC', sans-serif; }
|
|
|
-.design-panel__dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
|
|
|
+.design-panel__table th {
|
|
|
+ background: rgba(15, 19, 26, 0.7);
|
|
|
+ font-weight: 600;
|
|
|
+ font-family: 'PingFang SC', sans-serif;
|
|
|
+}
|
|
|
+.design-panel__row {
|
|
|
+ cursor: pointer;
|
|
|
+ user-select: none;
|
|
|
+ transition: background 120ms ease, box-shadow 120ms ease;
|
|
|
+}
|
|
|
+.design-panel__row:hover {
|
|
|
+ background: rgba(123, 208, 255, 0.06);
|
|
|
+}
|
|
|
+.design-panel__row:focus-visible {
|
|
|
+ outline: 2px solid rgba(123, 208, 255, 0.5);
|
|
|
+ outline-offset: -2px;
|
|
|
+}
|
|
|
+.design-panel__row--active {
|
|
|
+ background: rgba(123, 208, 255, 0.16);
|
|
|
+ box-shadow: inset 0 0 0 1px rgba(123, 208, 255, 0.7);
|
|
|
+}
|
|
|
+.design-panel__dot {
|
|
|
+ display: inline-block;
|
|
|
+ width: 10px;
|
|
|
+ height: 10px;
|
|
|
+ border-radius: 50%;
|
|
|
+ vertical-align: middle;
|
|
|
+ margin-right: 6px;
|
|
|
+}
|
|
|
+.design-panel__rate {
|
|
|
+ font-size: 11px;
|
|
|
+ color: var(--order-text-secondary, #c6c6cd);
|
|
|
+}
|
|
|
</style>
|