Files
DashBoard/frontend/src/mid-section-defect/App.vue

290 lines
7.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { reactive, ref } from 'vue';
import { apiGet, ensureMesApiAvailable } from '../core/api.js';
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
import FilterBar from './components/FilterBar.vue';
import KpiCards from './components/KpiCards.vue';
import ParetoChart from './components/ParetoChart.vue';
import TrendChart from './components/TrendChart.vue';
import DetailTable from './components/DetailTable.vue';
ensureMesApiAvailable();
const API_TIMEOUT = 120000; // 2min (genealogy can be slow)
const PAGE_SIZE = 200;
const filters = reactive({
startDate: '',
endDate: '',
lossReasons: [],
});
const committedFilters = ref({
startDate: '',
endDate: '',
lossReasons: [],
});
const availableLossReasons = ref([]);
const analysisData = ref({
kpi: {},
charts: {},
daily_trend: [],
genealogy_status: 'ready',
detail_total_count: 0,
});
const detailData = ref([]);
const detailPagination = ref({
page: 1,
page_size: PAGE_SIZE,
total_count: 0,
total_pages: 1,
});
const detailLoading = ref(false);
const loading = reactive({
initial: false,
querying: false,
});
const hasQueried = ref(false);
const queryError = ref('');
function setDefaultDates() {
const today = new Date();
const endDate = new Date(today);
endDate.setDate(endDate.getDate() - 1);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 6);
filters.startDate = toDateString(startDate);
filters.endDate = toDateString(endDate);
}
function toDateString(value) {
return value.toISOString().slice(0, 10);
}
function unwrapApiResult(result, fallbackMessage) {
if (result?.success === true) {
return result;
}
throw new Error(result?.error || fallbackMessage);
}
function buildFilterParams() {
const snapshot = committedFilters.value;
const params = {
start_date: snapshot.startDate,
end_date: snapshot.endDate,
};
if (snapshot.lossReasons.length) {
params.loss_reasons = snapshot.lossReasons.join(',');
}
return params;
}
function snapshotFilters() {
committedFilters.value = {
startDate: filters.startDate,
endDate: filters.endDate,
lossReasons: [...filters.lossReasons],
};
}
async function loadLossReasons() {
try {
const result = await apiGet('/api/mid-section-defect/loss-reasons');
const unwrapped = unwrapApiResult(result, '載入不良原因失敗');
availableLossReasons.value = unwrapped.data?.loss_reasons || [];
} catch {
// Non-blocking — dropdown will be empty until first query
}
}
async function loadDetail(page = 1, signal = null) {
detailLoading.value = true;
try {
const params = {
...buildFilterParams(),
page,
page_size: PAGE_SIZE,
};
const result = await apiGet('/api/mid-section-defect/analysis/detail', {
params,
timeout: API_TIMEOUT,
signal,
});
const unwrapped = unwrapApiResult(result, '載入明細失敗');
detailData.value = unwrapped.data?.detail || [];
detailPagination.value = unwrapped.data?.pagination || {
page: 1, page_size: PAGE_SIZE, total_count: 0, total_pages: 1,
};
} catch (err) {
if (err?.name === 'AbortError') {
return;
}
console.error('Detail load failed:', err.message);
detailData.value = [];
} finally {
detailLoading.value = false;
}
}
async function loadAnalysis() {
queryError.value = '';
loading.querying = true;
const signal = createAbortSignal('msd-analysis');
try {
const params = buildFilterParams();
// Fire summary and detail page 1 in parallel
const [summaryResult] = await Promise.all([
apiGet('/api/mid-section-defect/analysis', {
params,
timeout: API_TIMEOUT,
signal,
}),
loadDetail(1, signal),
]);
const unwrapped = unwrapApiResult(summaryResult, '查詢失敗');
analysisData.value = unwrapped.data;
hasQueried.value = true;
// Start auto-refresh after first successful query
if (!autoRefreshStarted) {
autoRefreshStarted = true;
startAutoRefresh();
}
} catch (err) {
if (err?.name === 'AbortError') {
return;
}
queryError.value = err.message || '查詢失敗,請稍後再試';
} finally {
loading.querying = false;
}
}
function handleUpdateFilters(updated) {
Object.assign(filters, updated);
}
function handleQuery() {
snapshotFilters();
loadAnalysis();
}
function prevPage() {
if (detailPagination.value.page <= 1) return;
loadDetail(detailPagination.value.page - 1, createAbortSignal('msd-detail'));
}
function nextPage() {
if (detailPagination.value.page >= detailPagination.value.total_pages) return;
loadDetail(detailPagination.value.page + 1, createAbortSignal('msd-detail'));
}
function exportCsv() {
const snapshot = committedFilters.value;
const params = new URLSearchParams({
start_date: snapshot.startDate,
end_date: snapshot.endDate,
});
if (snapshot.lossReasons.length) {
params.set('loss_reasons', snapshot.lossReasons.join(','));
}
const link = document.createElement('a');
link.href = `/api/mid-section-defect/export?${params}`;
link.download = `mid_section_defect_${snapshot.startDate}_to_${snapshot.endDate}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
let autoRefreshStarted = false;
const { createAbortSignal, startAutoRefresh } = useAutoRefresh({
onRefresh: () => loadAnalysis(),
intervalMs: 5 * 60 * 1000,
autoStart: false,
refreshOnVisible: true,
});
function initPage() {
setDefaultDates();
snapshotFilters();
loadLossReasons();
}
void initPage();
</script>
<template>
<div class="page-container">
<header class="page-header">
<h1>中段製程不良追溯分析</h1>
<p class="header-desc">TMTT 測試站不良回溯至上游機台 / 站點 / 製程</p>
</header>
<FilterBar
:filters="filters"
:loading="loading.querying"
:available-loss-reasons="availableLossReasons"
@update-filters="handleUpdateFilters"
@query="handleQuery"
/>
<div v-if="queryError" class="error-banner">{{ queryError }}</div>
<template v-if="hasQueried">
<div v-if="analysisData.genealogy_status === 'error'" class="warning-banner">
追溯分析未完成genealogy 查詢失敗圖表僅顯示 TMTT 站點數據
</div>
<KpiCards :kpi="analysisData.kpi" :loading="loading.querying" />
<div class="charts-section">
<div class="charts-row">
<ParetoChart title="依站點歸因" :data="analysisData.charts?.by_station" />
<ParetoChart title="依不良原因" :data="analysisData.charts?.by_loss_reason" />
</div>
<div class="charts-row">
<ParetoChart title="依上游機台歸因" :data="analysisData.charts?.by_machine" />
<ParetoChart title="依 TMTT 機台" :data="analysisData.charts?.by_tmtt_machine" />
</div>
<div class="charts-row">
<ParetoChart title="依製程 (WORKFLOW)" :data="analysisData.charts?.by_workflow" />
<ParetoChart title="依封裝 (PACKAGE)" :data="analysisData.charts?.by_package" />
</div>
<div class="charts-row charts-row-full">
<TrendChart :data="analysisData.daily_trend" />
</div>
</div>
<DetailTable
:data="detailData"
:loading="detailLoading"
:pagination="detailPagination"
@export-csv="exportCsv"
@prev-page="prevPage"
@next-page="nextPage"
/>
</template>
<div v-else-if="!loading.querying" class="empty-state">
<p>請選擇日期範圍與不良原因點擊查詢開始分析</p>
</div>
<div class="loading-overlay" :class="{ hidden: !loading.querying }">
<div class="loading-spinner"></div>
</div>
</div>
</template>