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:
egg
2026-02-25 09:02:39 +08:00
parent 983737ca1a
commit 86984cfeb1
28 changed files with 1768 additions and 86 deletions

View File

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

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

View File

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

View File

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

View File

@@ -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')">&times;</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>

View File

@@ -463,6 +463,10 @@ body {
grid-template-columns: 1fr;
}
.chart-with-panel {
position: relative;
}
.chart-card {
background: var(--msd-card-bg);
border-radius: 10px;

View File

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

View File

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

View File

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

View File

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

View File

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