feat: dimension pareto cache-based computation, filter propagation, and MSD events cache isolation
Reject History: - Compute dimension pareto (package/type/workflow/workcenter/equipment) from cached DataFrame instead of re-querying Oracle per dimension change - Propagate supplementary filters and trend date selection to dimension pareto - Add staleness tracking to prevent race conditions on rapid dimension switches - Add WORKFLOWNAME to detail and export outputs - Fix button hover visibility with CSS specificity MSD (製程不良追溯分析): - Separate raw events caching from aggregation computation so changing loss_reasons uses EventFetcher per-domain cache (fast) and recomputes aggregation with current filters instead of returning stale cached results - Exclude loss_reasons from MSD seed cache key since seed resolution does not use it, avoiding unnecessary Oracle re-queries - Add suspect context panel, analysis summary, upstream station/spec filters - Add machine bar click drill-down and filtered attribution charts Query Tool: - Support batch container_ids in lot CSV export (history/materials/rejects/holds) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,9 @@ import KpiCards from './components/KpiCards.vue';
|
||||
import MultiSelect from './components/MultiSelect.vue';
|
||||
import ParetoChart from './components/ParetoChart.vue';
|
||||
import TrendChart from './components/TrendChart.vue';
|
||||
import AnalysisSummary from './components/AnalysisSummary.vue';
|
||||
import DetailTable from './components/DetailTable.vue';
|
||||
import SuspectContextPanel from './components/SuspectContextPanel.vue';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
|
||||
@@ -170,6 +172,12 @@ const filteredByMachineData = computed(() => {
|
||||
return filtered.length > 0 ? buildMachineChartFromAttribution(filtered) : [];
|
||||
});
|
||||
|
||||
const suspectMachineNames = computed(() => {
|
||||
const data = filteredByMachineData.value;
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.filter((d) => d.name && d.name !== '其他').map((d) => d.name);
|
||||
});
|
||||
|
||||
const isForward = computed(() => committedFilters.value.direction === 'forward');
|
||||
const committedStation = computed(() => {
|
||||
const key = committedFilters.value.station || '測試';
|
||||
@@ -198,7 +206,25 @@ const eventsAggregation = computed(() => trace.stage_results.events?.aggregation
|
||||
const showAnalysisSkeleton = computed(() => hasQueried.value && loading.querying && !eventsAggregation.value);
|
||||
const showAnalysisCharts = computed(() => hasQueried.value && (Boolean(eventsAggregation.value) || restoredFromCache.value));
|
||||
|
||||
const skeletonChartCount = computed(() => (isForward.value ? 4 : 6));
|
||||
const skeletonChartCount = computed(() => (isForward.value ? 4 : 5));
|
||||
|
||||
const totalAncestorCount = computed(() => trace.stage_results.lineage?.total_ancestor_count || analysisData.value?.total_ancestor_count || 0);
|
||||
|
||||
const summaryQueryParams = computed(() => {
|
||||
const snap = committedFilters.value;
|
||||
const params = {
|
||||
queryMode: snap.queryMode || 'date_range',
|
||||
startDate: snap.startDate,
|
||||
endDate: snap.endDate,
|
||||
lossReasons: snap.lossReasons || [],
|
||||
};
|
||||
if (snap.queryMode === 'container') {
|
||||
params.containerInputType = snap.containerInputType || 'lot';
|
||||
params.resolvedCount = resolutionInfo.value?.resolved_count || 0;
|
||||
params.notFoundCount = resolutionInfo.value?.not_found?.length || 0;
|
||||
}
|
||||
return params;
|
||||
});
|
||||
|
||||
function emptyAnalysisData() {
|
||||
return {
|
||||
@@ -364,6 +390,7 @@ async function loadAnalysis() {
|
||||
analysisData.value = {
|
||||
...analysisData.value,
|
||||
...eventsAggregation.value,
|
||||
total_ancestor_count: trace.stage_results.lineage?.total_ancestor_count || 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -429,6 +456,21 @@ function exportCsv() {
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
// Suspect context panel state
|
||||
const suspectPanelMachine = ref(null);
|
||||
|
||||
function handleMachineBarClick({ name, dataIndex }) {
|
||||
if (!name || name === '其他') return;
|
||||
const attribution = analysisData.value?.attribution;
|
||||
if (!Array.isArray(attribution)) return;
|
||||
const match = attribution.find(
|
||||
(rec) => rec.EQUIPMENT_NAME === name,
|
||||
);
|
||||
if (match) {
|
||||
suspectPanelMachine.value = suspectPanelMachine.value?.EQUIPMENT_NAME === name ? null : match;
|
||||
}
|
||||
}
|
||||
|
||||
const _abortControllers = new Map();
|
||||
function createAbortSignal(key = 'default') {
|
||||
const prev = _abortControllers.get(key);
|
||||
@@ -542,6 +584,14 @@ void initPage();
|
||||
|
||||
<transition name="trace-fade">
|
||||
<div v-if="showAnalysisCharts">
|
||||
<AnalysisSummary
|
||||
v-if="!isForward"
|
||||
:query-params="summaryQueryParams"
|
||||
:kpi="analysisData.kpi"
|
||||
:total-ancestor-count="totalAncestorCount"
|
||||
:station-label="committedStation"
|
||||
/>
|
||||
|
||||
<KpiCards
|
||||
:kpi="analysisData.kpi"
|
||||
:loading="false"
|
||||
@@ -552,35 +602,40 @@ void initPage();
|
||||
<div class="charts-section">
|
||||
<template v-if="!isForward">
|
||||
<div class="charts-row">
|
||||
<ParetoChart title="依上游機台歸因" :data="filteredByMachineData">
|
||||
<template #header-extra>
|
||||
<div class="chart-inline-filters">
|
||||
<MultiSelect
|
||||
v-if="upstreamStationOptions.length > 1"
|
||||
:model-value="upstreamStationFilter"
|
||||
:options="upstreamStationOptions"
|
||||
placeholder="全部站點"
|
||||
@update:model-value="upstreamStationFilter = $event; upstreamSpecFilter = []"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-if="upstreamSpecOptions.length > 1"
|
||||
:model-value="upstreamSpecFilter"
|
||||
:options="upstreamSpecOptions"
|
||||
placeholder="全部型號"
|
||||
@update:model-value="upstreamSpecFilter = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ParetoChart>
|
||||
<div class="chart-with-panel">
|
||||
<ParetoChart title="依上游機台歸因" :data="filteredByMachineData" enable-click @bar-click="handleMachineBarClick">
|
||||
<template #header-extra>
|
||||
<div class="chart-inline-filters">
|
||||
<MultiSelect
|
||||
v-if="upstreamStationOptions.length > 1"
|
||||
:model-value="upstreamStationFilter"
|
||||
:options="upstreamStationOptions"
|
||||
placeholder="全部站點"
|
||||
@update:model-value="upstreamStationFilter = $event; upstreamSpecFilter = []"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-if="upstreamSpecOptions.length > 1"
|
||||
:model-value="upstreamSpecFilter"
|
||||
:options="upstreamSpecOptions"
|
||||
placeholder="全部型號"
|
||||
@update:model-value="upstreamSpecFilter = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ParetoChart>
|
||||
<SuspectContextPanel
|
||||
:machine="suspectPanelMachine"
|
||||
@close="suspectPanelMachine = null"
|
||||
/>
|
||||
</div>
|
||||
<ParetoChart title="依原物料歸因" :data="analysisData.charts?.by_material" />
|
||||
</div>
|
||||
<div class="charts-row">
|
||||
<ParetoChart title="依源頭批次歸因" :data="analysisData.charts?.by_wafer_root" />
|
||||
<ParetoChart title="依不良原因" :data="analysisData.charts?.by_loss_reason" />
|
||||
</div>
|
||||
<div class="charts-row">
|
||||
<ParetoChart title="依偵測機台" :data="analysisData.charts?.by_detection_machine" />
|
||||
<ParetoChart title="依製程 (WORKFLOW)" :data="analysisData.charts?.by_workflow" />
|
||||
</div>
|
||||
<div class="charts-row">
|
||||
<ParetoChart title="依封裝 (PACKAGE)" :data="analysisData.charts?.by_package" />
|
||||
<ParetoChart title="依 TYPE" :data="analysisData.charts?.by_pj_type" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -606,6 +661,7 @@ void initPage();
|
||||
:loading="detailLoading"
|
||||
:pagination="detailPagination"
|
||||
:direction="committedFilters.direction"
|
||||
:suspect-machines="suspectMachineNames"
|
||||
@export-csv="exportCsv"
|
||||
@prev-page="prevPage"
|
||||
@next-page="nextPage"
|
||||
|
||||
163
frontend/src/mid-section-defect/components/AnalysisSummary.vue
Normal file
163
frontend/src/mid-section-defect/components/AnalysisSummary.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
const STORAGE_KEY = 'msd:summary-collapsed';
|
||||
|
||||
const props = defineProps({
|
||||
queryParams: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
kpi: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
totalAncestorCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
stationLabel: {
|
||||
type: String,
|
||||
default: '測試',
|
||||
},
|
||||
});
|
||||
|
||||
const collapsed = ref(false);
|
||||
|
||||
// Restore from sessionStorage
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved === 'true') collapsed.value = true;
|
||||
} catch { /* unavailable */ }
|
||||
|
||||
watch(collapsed, (val) => {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, val ? 'true' : 'false');
|
||||
} catch { /* quota */ }
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
|
||||
function formatNumber(v) {
|
||||
if (v == null || v === 0) return '0';
|
||||
return Number(v).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="summary-panel">
|
||||
<div class="summary-header" @click="toggle">
|
||||
<h3 class="summary-title">分析摘要</h3>
|
||||
<span class="summary-toggle">{{ collapsed ? '▸ 展開' : '▾ 收起' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-show="!collapsed" class="summary-body">
|
||||
<div class="summary-grid">
|
||||
<!-- Query context -->
|
||||
<div class="summary-block">
|
||||
<h4 class="block-title">查詢條件</h4>
|
||||
<ul class="block-list">
|
||||
<li>偵測站:{{ stationLabel }}</li>
|
||||
<template v-if="queryParams.queryMode === 'container'">
|
||||
<li>輸入方式:{{ queryParams.containerInputType === 'lot' ? 'LOT ID' : queryParams.containerInputType }}</li>
|
||||
<li v-if="queryParams.resolvedCount != null">解析數量:{{ queryParams.resolvedCount }} 筆</li>
|
||||
<li v-if="queryParams.notFoundCount > 0">未找到:{{ queryParams.notFoundCount }} 筆</li>
|
||||
</template>
|
||||
<template v-else>
|
||||
<li>日期範圍:{{ queryParams.startDate }} ~ {{ queryParams.endDate }}</li>
|
||||
</template>
|
||||
<li>不良原因:{{ queryParams.lossReasons?.length ? queryParams.lossReasons.join(', ') : '全部' }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Data scope -->
|
||||
<div class="summary-block">
|
||||
<h4 class="block-title">數據範圍</h4>
|
||||
<ul class="block-list">
|
||||
<li>偵測站 LOT 總數:{{ formatNumber(kpi.lot_count) }}</li>
|
||||
<li>總投入:{{ formatNumber(kpi.total_input) }} pcs</li>
|
||||
<li>報廢 LOT 數:{{ formatNumber(kpi.defective_lot_count) }}</li>
|
||||
<li>報廢總數:{{ formatNumber(kpi.total_defect_qty) }} pcs</li>
|
||||
<li>血緣追溯涵蓋上游 LOT:{{ formatNumber(totalAncestorCount) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Methodology -->
|
||||
<div class="summary-block summary-block-wide">
|
||||
<h4 class="block-title">歸因方法說明</h4>
|
||||
<p class="block-text">
|
||||
分析涵蓋所有經過偵測站的 LOT(包含無不良者),針對每筆 LOT 回溯血緣(split / merge chain)找到關聯的上游因子。
|
||||
歸因不良率 = 關聯 LOT 的報廢合計 / 關聯 LOT 的投入合計 × 100%。
|
||||
同一筆不良可歸因於多個上游因子(非互斥)。
|
||||
柏拉圖柱高 = 歸因不良數(含重疊),橙線 = 歸因不良率。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.summary-panel {
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.summary-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.summary-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
.summary-toggle {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
}
|
||||
.summary-body {
|
||||
padding: 0 16px 14px;
|
||||
}
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px 24px;
|
||||
}
|
||||
.summary-block {
|
||||
min-width: 0;
|
||||
}
|
||||
.summary-block-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.block-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
margin: 0 0 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.block-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #374151);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.block-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'backward',
|
||||
},
|
||||
suspectMachines: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['export-csv', 'prev-page', 'next-page']);
|
||||
@@ -38,7 +42,8 @@ const COLUMNS_BACKWARD = [
|
||||
{ key: 'DEFECT_QTY', label: '不良數', width: '70px', numeric: true },
|
||||
{ key: 'DEFECT_RATE', label: '不良率(%)', width: '90px', numeric: true },
|
||||
{ key: 'ANCESTOR_COUNT', label: '上游LOT數', width: '80px', numeric: true },
|
||||
{ key: 'UPSTREAM_MACHINES', label: '上游機台', width: '200px' },
|
||||
{ key: 'UPSTREAM_MACHINE_COUNT', label: '上游台數', width: '80px', numeric: true },
|
||||
{ key: 'SUSPECT_HITS', label: '嫌疑命中', width: '200px', custom: true },
|
||||
];
|
||||
|
||||
const COLUMNS_FORWARD = [
|
||||
@@ -103,6 +108,27 @@ function formatCell(value, col) {
|
||||
if (col.numeric) return Number(value).toLocaleString();
|
||||
return value;
|
||||
}
|
||||
|
||||
function getSuspectHits(row) {
|
||||
const upstreamMachines = row.UPSTREAM_MACHINES;
|
||||
if (!Array.isArray(upstreamMachines) || upstreamMachines.length === 0) return null;
|
||||
const suspects = props.suspectMachines;
|
||||
if (!suspects || suspects.length === 0) return null;
|
||||
|
||||
const suspectSet = new Set(suspects);
|
||||
const machineNames = upstreamMachines.map((m) => m.machine || m);
|
||||
const uniqueNames = [...new Set(machineNames)];
|
||||
const hits = uniqueNames.filter((name) => suspectSet.has(name));
|
||||
|
||||
if (hits.length === 0) return null;
|
||||
|
||||
return {
|
||||
hitNames: hits,
|
||||
hitCount: hits.length,
|
||||
totalCount: uniqueNames.length,
|
||||
fullMatch: hits.length === uniqueNames.length,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -136,7 +162,14 @@ function formatCell(value, col) {
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in sortedData" :key="idx">
|
||||
<td v-for="col in activeColumns" :key="col.key" :class="{ numeric: col.numeric }">
|
||||
{{ formatCell(row[col.key], col) }}
|
||||
<template v-if="col.key === 'SUSPECT_HITS'">
|
||||
<span v-if="getSuspectHits(row)" :class="{ 'hit-full': getSuspectHits(row).fullMatch }" class="suspect-cell">
|
||||
{{ getSuspectHits(row).hitNames.join(', ') }}
|
||||
<span class="hit-ratio">({{ getSuspectHits(row).hitCount }}/{{ getSuspectHits(row).totalCount }})</span>
|
||||
</span>
|
||||
<span v-else class="no-hit">-</span>
|
||||
</template>
|
||||
<template v-else>{{ formatCell(row[col.key], col) }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!sortedData.length">
|
||||
@@ -156,3 +189,21 @@ function formatCell(value, col) {
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.suspect-cell {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #374151);
|
||||
}
|
||||
.hit-ratio {
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
margin-left: 4px;
|
||||
}
|
||||
.hit-full {
|
||||
color: #059669;
|
||||
font-weight: 600;
|
||||
}
|
||||
.no-hit {
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import VChart from 'vue-echarts';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
@@ -7,10 +7,11 @@ import { BarChart, LineChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
MarkLineComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
|
||||
use([CanvasRenderer, BarChart, LineChart, GridComponent, LegendComponent, TooltipComponent]);
|
||||
use([CanvasRenderer, BarChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent]);
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
@@ -21,15 +22,51 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
enableClick: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['bar-click']);
|
||||
|
||||
// Sort toggle: 'qty' (default) or 'rate'
|
||||
const sortMode = ref('qty');
|
||||
|
||||
function toggleSort() {
|
||||
sortMode.value = sortMode.value === 'qty' ? 'rate' : 'qty';
|
||||
}
|
||||
|
||||
const sortedData = computed(() => {
|
||||
if (!props.data || !props.data.length) return [];
|
||||
if (sortMode.value === 'qty') return props.data;
|
||||
|
||||
// Re-sort by defect_rate and recalculate cumulative %
|
||||
const sorted = [...props.data].sort((a, b) => (b.defect_rate || 0) - (a.defect_rate || 0));
|
||||
const totalDefects = sorted.reduce((s, d) => s + (d.defect_qty || 0), 0);
|
||||
let cumsum = 0;
|
||||
return sorted.map((item) => {
|
||||
cumsum += item.defect_qty || 0;
|
||||
return {
|
||||
...item,
|
||||
cumulative_pct: totalDefects > 0 ? Math.round((cumsum / totalDefects) * 1e4) / 100 : 0,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const totalLotCount = computed(() => {
|
||||
if (!props.data || !props.data.length) return 0;
|
||||
return props.data.reduce((s, d) => s + (d.lot_count || 0), 0);
|
||||
});
|
||||
|
||||
const chartOption = computed(() => {
|
||||
if (!props.data || !props.data.length) return null;
|
||||
const data = sortedData.value;
|
||||
if (!data.length) return null;
|
||||
|
||||
const names = props.data.map((d) => d.name);
|
||||
const defectQty = props.data.map((d) => d.defect_qty);
|
||||
const cumulativePct = props.data.map((d) => d.cumulative_pct);
|
||||
const defectRate = props.data.map((d) => d.defect_rate);
|
||||
const names = data.map((d) => d.name);
|
||||
const defectQty = data.map((d) => d.defect_qty);
|
||||
const cumulativePct = data.map((d) => d.cumulative_pct);
|
||||
const defectRate = data.map((d) => d.defect_rate);
|
||||
|
||||
return {
|
||||
animationDuration: 350,
|
||||
@@ -39,11 +76,16 @@ const chartOption = computed(() => {
|
||||
formatter(params) {
|
||||
const idx = params[0]?.dataIndex;
|
||||
if (idx == null) return '';
|
||||
const item = props.data[idx];
|
||||
const item = data[idx];
|
||||
const total = totalLotCount.value;
|
||||
let html = `<b>${item.name}</b><br/>`;
|
||||
html += `不良數: ${(item.defect_qty || 0).toLocaleString()}<br/>`;
|
||||
html += `投入數: ${(item.input_qty || 0).toLocaleString()}<br/>`;
|
||||
html += `不良率: ${(item.defect_rate || 0).toFixed(2)}%<br/>`;
|
||||
if (item.lot_count != null) {
|
||||
const pct = total > 0 ? ((item.lot_count / total) * 100).toFixed(1) : '0.0';
|
||||
html += `關聯 LOT 數: ${item.lot_count} (${pct}%)<br/>`;
|
||||
}
|
||||
html += `累計占比: ${(item.cumulative_pct || 0).toFixed(1)}%`;
|
||||
return html;
|
||||
},
|
||||
@@ -112,16 +154,38 @@ const chartOption = computed(() => {
|
||||
symbolSize: 6,
|
||||
lineStyle: { color: '#ef4444', width: 2, type: 'dashed' },
|
||||
itemStyle: { color: '#ef4444' },
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
label: { show: true, position: 'insideEndTop', formatter: '80%', fontSize: 10, color: '#94a3b8' },
|
||||
lineStyle: { color: '#94a3b8', type: 'dotted', width: 1 },
|
||||
data: [{ yAxis: 80 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
function handleChartClick(params) {
|
||||
if (!props.enableClick) return;
|
||||
if (params.componentType === 'series' && params.seriesType === 'bar') {
|
||||
emit('bar-click', { name: params.name, dataIndex: params.dataIndex });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">{{ title }}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="sort-toggle"
|
||||
:title="sortMode === 'qty' ? '切換為依不良率排序' : '切換為依不良數排序'"
|
||||
@click="toggleSort"
|
||||
>
|
||||
{{ sortMode === 'qty' ? '依數量' : '依比率' }}
|
||||
</button>
|
||||
<slot name="header-extra" />
|
||||
</div>
|
||||
<VChart
|
||||
@@ -129,7 +193,25 @@ const chartOption = computed(() => {
|
||||
class="chart-canvas"
|
||||
:option="chartOption"
|
||||
autoresize
|
||||
@click="handleChartClick"
|
||||
/>
|
||||
<div v-else class="chart-empty">暫無資料</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sort-toggle {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--border-color, #d1d5db);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
cursor: pointer;
|
||||
margin-left: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sort-toggle:hover {
|
||||
background: var(--bg-tertiary, #f3f4f6);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
import { apiGet } from '../../core/api.js';
|
||||
|
||||
const props = defineProps({
|
||||
machine: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const jobsLoading = ref(false);
|
||||
const jobs = ref([]);
|
||||
const jobsError = ref('');
|
||||
|
||||
watch(() => props.machine, async (val) => {
|
||||
jobs.value = [];
|
||||
jobsError.value = '';
|
||||
if (!val?.EQUIPMENT_ID) return;
|
||||
jobsLoading.value = true;
|
||||
try {
|
||||
const result = await apiGet(`/api/query-tool/equipment-recent-jobs/${encodeURIComponent(val.EQUIPMENT_ID)}`);
|
||||
if (Array.isArray(result?.data)) {
|
||||
jobs.value = result.data;
|
||||
}
|
||||
} catch (err) {
|
||||
jobsError.value = err.message || '載入維修紀錄失敗';
|
||||
} finally {
|
||||
jobsLoading.value = false;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
function handleOutsideClick(e) {
|
||||
const el = e.target.closest('.suspect-panel');
|
||||
if (!el) emit('close');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => document.addEventListener('click', handleOutsideClick), 0);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleOutsideClick);
|
||||
});
|
||||
|
||||
function formatDate(v) {
|
||||
if (!v) return '-';
|
||||
return String(v).slice(0, 16).replace('T', ' ');
|
||||
}
|
||||
|
||||
function formatNumber(v) {
|
||||
if (v == null) return '0';
|
||||
return Number(v).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="machine" class="suspect-panel" @click.stop>
|
||||
<div class="panel-header">
|
||||
<h4 class="panel-title">{{ machine.EQUIPMENT_NAME }}</h4>
|
||||
<button type="button" class="panel-close" @click="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h5 class="section-label">歸因摘要</h5>
|
||||
<table class="attr-table">
|
||||
<tbody>
|
||||
<tr><td class="attr-key">站點</td><td>{{ machine.WORKCENTER_GROUP || '-' }}</td></tr>
|
||||
<tr><td class="attr-key">機型</td><td>{{ machine.RESOURCEFAMILYNAME || '-' }}</td></tr>
|
||||
<tr><td class="attr-key">歸因不良率</td><td>{{ machine.DEFECT_RATE != null ? Number(machine.DEFECT_RATE).toFixed(2) + '%' : '-' }}</td></tr>
|
||||
<tr><td class="attr-key">歸因不良數</td><td>{{ formatNumber(machine.DEFECT_QTY) }}</td></tr>
|
||||
<tr><td class="attr-key">歸因投入數</td><td>{{ formatNumber(machine.INPUT_QTY) }}</td></tr>
|
||||
<tr><td class="attr-key">關聯 LOT 數</td><td>{{ formatNumber(machine.DETECTION_LOT_COUNT) }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h5 class="section-label">近期維修紀錄</h5>
|
||||
<div v-if="jobsLoading" class="jobs-loading">載入中...</div>
|
||||
<div v-else-if="jobsError" class="jobs-error">{{ jobsError }}</div>
|
||||
<div v-else-if="jobs.length === 0" class="jobs-empty">近 30 天無維修紀錄</div>
|
||||
<table v-else class="jobs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>JOB ID</th>
|
||||
<th>狀態</th>
|
||||
<th>型號</th>
|
||||
<th>維修區間</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="job in jobs" :key="job.JOBID">
|
||||
<td>{{ job.JOBID || '-' }}</td>
|
||||
<td>{{ job.JOBSTATUS || '-' }}</td>
|
||||
<td>{{ job.JOBMODELNAME || '-' }}</td>
|
||||
<td class="job-interval">
|
||||
<span>{{ formatDate(job.CREATEDATE) }}</span>
|
||||
<span v-if="job.COMPLETEDATE" class="interval-sep">→</span>
|
||||
<span v-if="job.COMPLETEDATE">{{ formatDate(job.COMPLETEDATE) }}</span>
|
||||
<span v-else class="interval-ongoing">進行中</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.suspect-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -320px;
|
||||
width: 300px;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
z-index: 100;
|
||||
font-size: 13px;
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.panel-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.panel-section {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.panel-section + .panel-section {
|
||||
border-top: 1px solid var(--border-color, #f3f4f6);
|
||||
}
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.attr-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.attr-table td {
|
||||
padding: 3px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.attr-key {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
width: 90px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.jobs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.jobs-table th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
padding: 4px 6px 4px 0;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
font-size: 11px;
|
||||
}
|
||||
.jobs-table td {
|
||||
padding: 4px 6px 4px 0;
|
||||
border-bottom: 1px solid var(--border-color, #f3f4f6);
|
||||
}
|
||||
.job-interval {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.interval-sep {
|
||||
margin: 0 2px;
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
}
|
||||
.interval-ongoing {
|
||||
color: #f59e0b;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.jobs-loading,
|
||||
.jobs-empty {
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.jobs-error {
|
||||
color: #ef4444;
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -463,6 +463,10 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-with-panel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: var(--msd-card-bg);
|
||||
border-radius: 10px;
|
||||
|
||||
@@ -62,6 +62,9 @@ const page = ref(1);
|
||||
const detailReason = ref('');
|
||||
const selectedTrendDates = ref([]);
|
||||
const trendLegendSelected = ref({ '扣帳報廢量': true, '不扣帳報廢量': true });
|
||||
const paretoDimension = ref('reason');
|
||||
const dimensionParetoItems = ref([]);
|
||||
const dimensionParetoLoading = ref(false);
|
||||
|
||||
// ---- Data state ----
|
||||
const summary = ref({
|
||||
@@ -197,6 +200,8 @@ async function executePrimaryQuery() {
|
||||
page.value = 1;
|
||||
detailReason.value = '';
|
||||
selectedTrendDates.value = [];
|
||||
paretoDimension.value = 'reason';
|
||||
dimensionParetoItems.value = [];
|
||||
|
||||
// Apply initial data
|
||||
analyticsRawItems.value = Array.isArray(result.analytics_raw)
|
||||
@@ -301,6 +306,7 @@ function onTrendDateClick(dateStr) {
|
||||
}
|
||||
page.value = 1;
|
||||
void refreshView();
|
||||
refreshDimensionParetoIfActive();
|
||||
}
|
||||
|
||||
function onTrendLegendChange(selected) {
|
||||
@@ -308,6 +314,7 @@ function onTrendLegendChange(selected) {
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void refreshView();
|
||||
refreshDimensionParetoIfActive();
|
||||
}
|
||||
|
||||
function onParetoClick(reason) {
|
||||
@@ -323,6 +330,59 @@ function handleParetoScopeToggle(checked) {
|
||||
updateUrlState();
|
||||
}
|
||||
|
||||
let activeDimRequestId = 0;
|
||||
|
||||
async function fetchDimensionPareto(dim) {
|
||||
if (dim === 'reason' || !queryId.value) return;
|
||||
activeDimRequestId += 1;
|
||||
const myId = activeDimRequestId;
|
||||
dimensionParetoLoading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
query_id: queryId.value,
|
||||
start_date: committedPrimary.startDate,
|
||||
end_date: committedPrimary.endDate,
|
||||
dimension: dim,
|
||||
metric_mode: paretoMetricMode.value === 'defect' ? 'defect' : 'reject_total',
|
||||
pareto_scope: committedPrimary.paretoTop80 ? 'top80' : 'all',
|
||||
include_excluded_scrap: committedPrimary.includeExcludedScrap,
|
||||
exclude_material_scrap: committedPrimary.excludeMaterialScrap,
|
||||
exclude_pb_diode: committedPrimary.excludePbDiode,
|
||||
packages: supplementaryFilters.packages.length > 0 ? supplementaryFilters.packages : undefined,
|
||||
workcenter_groups: supplementaryFilters.workcenterGroups.length > 0 ? supplementaryFilters.workcenterGroups : undefined,
|
||||
reason: supplementaryFilters.reason || undefined,
|
||||
trend_dates: selectedTrendDates.value.length > 0 ? selectedTrendDates.value : undefined,
|
||||
};
|
||||
const resp = await apiGet('/api/reject-history/reason-pareto', { params, timeout: API_TIMEOUT });
|
||||
if (myId !== activeDimRequestId) return;
|
||||
const result = unwrapApiResult(resp, '查詢維度 Pareto 失敗');
|
||||
dimensionParetoItems.value = result.data?.items || [];
|
||||
} catch (err) {
|
||||
if (myId !== activeDimRequestId) return;
|
||||
dimensionParetoItems.value = [];
|
||||
errorMessage.value = err.message || '查詢維度 Pareto 失敗';
|
||||
} finally {
|
||||
if (myId === activeDimRequestId) {
|
||||
dimensionParetoLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refreshDimensionParetoIfActive() {
|
||||
if (paretoDimension.value !== 'reason') {
|
||||
void fetchDimensionPareto(paretoDimension.value);
|
||||
}
|
||||
}
|
||||
|
||||
function onDimensionChange(dim) {
|
||||
paretoDimension.value = dim;
|
||||
if (dim === 'reason') {
|
||||
dimensionParetoItems.value = [];
|
||||
} else {
|
||||
void fetchDimensionPareto(dim);
|
||||
}
|
||||
}
|
||||
|
||||
function onSupplementaryChange(filters) {
|
||||
supplementaryFilters.packages = filters.packages || [];
|
||||
supplementaryFilters.workcenterGroups = filters.workcenterGroups || [];
|
||||
@@ -331,6 +391,7 @@ function onSupplementaryChange(filters) {
|
||||
detailReason.value = '';
|
||||
selectedTrendDates.value = [];
|
||||
void refreshView();
|
||||
refreshDimensionParetoIfActive();
|
||||
}
|
||||
|
||||
function removeFilterChip(chip) {
|
||||
@@ -347,6 +408,7 @@ function removeFilterChip(chip) {
|
||||
selectedTrendDates.value = [];
|
||||
page.value = 1;
|
||||
void refreshView();
|
||||
refreshDimensionParetoIfActive();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -354,6 +416,7 @@ function removeFilterChip(chip) {
|
||||
supplementaryFilters.reason = '';
|
||||
page.value = 1;
|
||||
void refreshView();
|
||||
refreshDimensionParetoIfActive();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -363,6 +426,7 @@ function removeFilterChip(chip) {
|
||||
);
|
||||
page.value = 1;
|
||||
void refreshView();
|
||||
refreshDimensionParetoIfActive();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -372,6 +436,7 @@ function removeFilterChip(chip) {
|
||||
);
|
||||
page.value = 1;
|
||||
void refreshView();
|
||||
refreshDimensionParetoIfActive();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -556,6 +621,11 @@ const filteredParetoItems = computed(() => {
|
||||
return items.slice(0, Math.max(top80Count, Math.min(5, items.length)));
|
||||
});
|
||||
|
||||
const activeParetoItems = computed(() => {
|
||||
if (paretoDimension.value !== 'reason') return dimensionParetoItems.value;
|
||||
return filteredParetoItems.value;
|
||||
});
|
||||
|
||||
const activeFilterChips = computed(() => {
|
||||
const chips = [];
|
||||
|
||||
@@ -871,12 +941,15 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<ParetoSection
|
||||
:items="filteredParetoItems"
|
||||
:items="activeParetoItems"
|
||||
:detail-reason="detailReason"
|
||||
:selected-dates="selectedTrendDates"
|
||||
:metric-label="paretoMetricLabel"
|
||||
:loading="loading.querying"
|
||||
:loading="loading.querying || dimensionParetoLoading"
|
||||
:dimension="paretoDimension"
|
||||
:show-dimension-selector="committedPrimary.mode === 'date_range'"
|
||||
@reason-click="onParetoClick"
|
||||
@dimension-change="onDimensionChange"
|
||||
/>
|
||||
|
||||
<DetailTable
|
||||
|
||||
@@ -41,6 +41,7 @@ function formatNumber(value) {
|
||||
<th>Package</th>
|
||||
<th>FUNCTION</th>
|
||||
<th class="col-left">TYPE</th>
|
||||
<th>WORKFLOW</th>
|
||||
<th>PRODUCT</th>
|
||||
<th>原因</th>
|
||||
<th>EQUIPMENT</th>
|
||||
@@ -66,6 +67,7 @@ function formatNumber(value) {
|
||||
<td>{{ row.PRODUCTLINENAME }}</td>
|
||||
<td>{{ row.PJ_FUNCTION || '' }}</td>
|
||||
<td class="col-left">{{ row.PJ_TYPE }}</td>
|
||||
<td>{{ row.WORKFLOWNAME || '' }}</td>
|
||||
<td>{{ row.PRODUCTNAME || '' }}</td>
|
||||
<td>{{ row.LOSSREASONNAME }}</td>
|
||||
<td>{{ row.EQUIPMENTNAME || '' }}</td>
|
||||
@@ -82,7 +84,7 @@ function formatNumber(value) {
|
||||
<td class="cell-nowrap">{{ row.TXN_TIME || row.TXN_DAY }}</td>
|
||||
</tr>
|
||||
<tr v-if="!items || items.length === 0">
|
||||
<td :colspan="showRejectBreakdown ? 17 : 12" class="placeholder">No data</td>
|
||||
<td :colspan="showRejectBreakdown ? 18 : 13" class="placeholder">No data</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -9,18 +9,34 @@ import VChart from 'vue-echarts';
|
||||
|
||||
use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent]);
|
||||
|
||||
const DIMENSION_OPTIONS = [
|
||||
{ value: 'reason', label: '不良原因' },
|
||||
{ value: 'package', label: 'PACKAGE' },
|
||||
{ value: 'type', label: 'TYPE' },
|
||||
{ value: 'workflow', label: 'WORKFLOW' },
|
||||
{ value: 'workcenter', label: '站點' },
|
||||
{ value: 'equipment', label: '機台' },
|
||||
];
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
detailReason: { type: String, default: '' },
|
||||
selectedDates: { type: Array, default: () => [] },
|
||||
metricLabel: { type: String, default: '報廢量' },
|
||||
loading: { type: Boolean, default: false },
|
||||
dimension: { type: String, default: 'reason' },
|
||||
showDimensionSelector: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['reason-click']);
|
||||
const emit = defineEmits(['reason-click', 'dimension-change']);
|
||||
|
||||
const hasData = computed(() => Array.isArray(props.items) && props.items.length > 0);
|
||||
|
||||
const dimensionLabel = computed(() => {
|
||||
const opt = DIMENSION_OPTIONS.find((o) => o.value === props.dimension);
|
||||
return opt ? opt.label : '報廢原因';
|
||||
});
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString('zh-TW');
|
||||
}
|
||||
@@ -108,7 +124,7 @@ const chartOption = computed(() => {
|
||||
});
|
||||
|
||||
function handleChartClick(params) {
|
||||
if (params?.seriesType !== 'bar') {
|
||||
if (params?.seriesType !== 'bar' || props.dimension !== 'reason') {
|
||||
return;
|
||||
}
|
||||
const reason = props.items?.[params.dataIndex]?.reason;
|
||||
@@ -122,9 +138,17 @@ function handleChartClick(params) {
|
||||
<section class="card">
|
||||
<div class="card-header pareto-header">
|
||||
<div class="card-title">
|
||||
{{ metricLabel }} vs 報廢原因(Pareto)
|
||||
{{ metricLabel }} vs {{ dimensionLabel }}(Pareto)
|
||||
<span v-for="d in selectedDates" :key="d" class="pareto-date-badge">{{ d }}</span>
|
||||
</div>
|
||||
<select
|
||||
v-if="showDimensionSelector"
|
||||
class="dimension-select"
|
||||
:value="dimension"
|
||||
@change="emit('dimension-change', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in DIMENSION_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card-body pareto-layout">
|
||||
<div class="pareto-chart-wrap">
|
||||
@@ -135,7 +159,7 @@ function handleChartClick(params) {
|
||||
<table class="detail-table pareto-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>原因</th>
|
||||
<th>{{ dimensionLabel }}</th>
|
||||
<th>{{ metricLabel }}</th>
|
||||
<th>占比</th>
|
||||
<th>累積</th>
|
||||
@@ -148,9 +172,10 @@ function handleChartClick(params) {
|
||||
:class="{ active: detailReason === item.reason }"
|
||||
>
|
||||
<td>
|
||||
<button class="reason-link" type="button" @click="$emit('reason-click', item.reason)">
|
||||
<button v-if="dimension === 'reason'" class="reason-link" type="button" @click="$emit('reason-click', item.reason)">
|
||||
{{ item.reason }}
|
||||
</button>
|
||||
<span v-else>{{ item.reason }}</span>
|
||||
</td>
|
||||
<td>{{ formatNumber(item.metric_value) }}</td>
|
||||
<td>{{ formatPct(item.pct) }}</td>
|
||||
|
||||
@@ -228,16 +228,27 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-export {
|
||||
.btn.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn.btn-export {
|
||||
background: #0f766e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-export:hover {
|
||||
.btn.btn-export:hover {
|
||||
background: #0b5e59;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-export:disabled {
|
||||
.btn.btn-export:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -296,6 +307,17 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dimension-select {
|
||||
font-size: 12px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border-color, #d1d5db);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary, #fff);
|
||||
color: var(--text-primary, #374151);
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.pareto-date-badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
|
||||
@@ -7,7 +7,7 @@ ensureMesApiAvailable();
|
||||
const DEFAULT_STAGE_TIMEOUT_MS = 60000;
|
||||
const PROFILE_DOMAINS = Object.freeze({
|
||||
query_tool: ['history', 'materials', 'rejects', 'holds', 'jobs'],
|
||||
mid_section_defect: ['upstream_history'],
|
||||
mid_section_defect: ['upstream_history', 'materials'],
|
||||
mid_section_defect_forward: ['upstream_history', 'downstream_rejects'],
|
||||
});
|
||||
|
||||
@@ -168,6 +168,7 @@ export function useTraceProgress({ profile } = {}) {
|
||||
lineage: {
|
||||
ancestors: lineagePayload?.ancestors || {},
|
||||
children_map: lineagePayload?.children_map || {},
|
||||
seed_roots: lineagePayload?.seed_roots || {},
|
||||
},
|
||||
},
|
||||
{ timeout: DEFAULT_STAGE_TIMEOUT_MS, signal: controller.signal },
|
||||
|
||||
Reference in New Issue
Block a user