feat(lineage): unified LineageEngine, EventFetcher, and progressive trace API
Introduce a unified Seed→Lineage→Event pipeline replacing per-page Python BFS with Oracle CONNECT BY NOCYCLE queries, add staged /api/trace/* endpoints with rate limiting and L2 Redis caching, and wire progressive frontend loading via useTraceProgress composable. Key changes: - Add LineageEngine (split ancestors / merge sources / full genealogy) with QueryBuilder bind-param safety and batched IN clauses - Add EventFetcher with 6-domain support and L2 Redis cache - Add trace_routes Blueprint (seed-resolve, lineage, events) with profile dispatch, rate limiting, and Redis TTL=300s caching - Refactor query_tool_service to use LineageEngine and QueryBuilder, removing raw string interpolation (SQL injection fix) - Add rate limits and resolve cache to query_tool_routes - Integrate useTraceProgress into mid-section-defect with skeleton placeholders and fade-in transitions - Add lineageCache and on-demand lot lineage to query-tool - Add TraceProgressBar shared component - Remove legacy query-tool.js static script (3k lines) - Fix MatrixTable package column truncation (.slice(0,15) removed) - Archive unified-lineage-engine change, add trace-progressive-ui specs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,22 @@
|
||||
<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;
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet, ensureMesApiAvailable } from '../core/api.js';
|
||||
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
|
||||
import { useTraceProgress } from '../shared-composables/useTraceProgress.js';
|
||||
import TraceProgressBar from '../shared-composables/TraceProgressBar.vue';
|
||||
|
||||
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;
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
const filters = reactive({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
@@ -25,58 +27,94 @@ const committedFilters = ref({
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
const availableLossReasons = ref([]);
|
||||
const trace = useTraceProgress({ profile: 'mid_section_defect' });
|
||||
|
||||
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({
|
||||
querying: false,
|
||||
});
|
||||
|
||||
const hasQueried = ref(false);
|
||||
const queryError = ref('');
|
||||
|
||||
const hasTraceError = computed(() => (
|
||||
Boolean(trace.stage_errors.seed)
|
||||
|| Boolean(trace.stage_errors.lineage)
|
||||
|| Boolean(trace.stage_errors.events)
|
||||
));
|
||||
const showTraceProgress = computed(() => (
|
||||
loading.querying
|
||||
|| trace.completed_stages.value.length > 0
|
||||
|| hasTraceError.value
|
||||
));
|
||||
const eventsAggregation = computed(() => trace.stage_results.events?.aggregation || null);
|
||||
const showAnalysisSkeleton = computed(() => hasQueried.value && loading.querying && !eventsAggregation.value);
|
||||
const showAnalysisCharts = computed(() => hasQueried.value && Boolean(eventsAggregation.value));
|
||||
|
||||
function emptyAnalysisData() {
|
||||
return {
|
||||
kpi: {},
|
||||
charts: {},
|
||||
daily_trend: [],
|
||||
genealogy_status: 'ready',
|
||||
detail_total_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function buildDetailParams() {
|
||||
const snapshot = committedFilters.value;
|
||||
const params = {
|
||||
start_date: snapshot.startDate,
|
||||
@@ -95,22 +133,27 @@ function snapshotFilters() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function firstStageErrorMessage() {
|
||||
const stageError = trace.stage_errors.seed || trace.stage_errors.lineage || trace.stage_errors.events;
|
||||
return stageError?.message || '';
|
||||
}
|
||||
|
||||
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 remains empty.
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail(page = 1, signal = null) {
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
...buildFilterParams(),
|
||||
...buildDetailParams(),
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
};
|
||||
@@ -119,11 +162,14 @@ async function loadDetail(page = 1, signal = null) {
|
||||
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,
|
||||
};
|
||||
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;
|
||||
@@ -133,35 +179,40 @@ async function loadDetail(page = 1, signal = null) {
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function loadAnalysis() {
|
||||
queryError.value = '';
|
||||
trace.abort();
|
||||
trace.reset();
|
||||
loading.querying = true;
|
||||
const signal = createAbortSignal('msd-analysis');
|
||||
hasQueried.value = true;
|
||||
analysisData.value = emptyAnalysisData();
|
||||
|
||||
try {
|
||||
const params = buildFilterParams();
|
||||
await trace.execute(params);
|
||||
|
||||
// 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();
|
||||
}
|
||||
if (eventsAggregation.value) {
|
||||
analysisData.value = {
|
||||
...analysisData.value,
|
||||
...eventsAggregation.value,
|
||||
};
|
||||
}
|
||||
|
||||
const stageError = firstStageErrorMessage();
|
||||
if (stageError) {
|
||||
queryError.value = stageError;
|
||||
}
|
||||
|
||||
if (!stageError || trace.completed_stages.value.includes('events')) {
|
||||
await loadDetail(1, createAbortSignal('msd-detail'));
|
||||
}
|
||||
|
||||
if (!autoRefreshStarted) {
|
||||
autoRefreshStarted = true;
|
||||
startAutoRefresh();
|
||||
}
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') {
|
||||
return;
|
||||
@@ -171,24 +222,24 @@ async function loadAnalysis() {
|
||||
loading.querying = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateFilters(updated) {
|
||||
Object.assign(filters, updated);
|
||||
}
|
||||
|
||||
|
||||
function handleUpdateFilters(updated) {
|
||||
Object.assign(filters, updated);
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
snapshotFilters();
|
||||
loadAnalysis();
|
||||
void loadAnalysis();
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (detailPagination.value.page <= 1) return;
|
||||
loadDetail(detailPagination.value.page - 1, createAbortSignal('msd-detail'));
|
||||
void 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'));
|
||||
void loadDetail(detailPagination.value.page + 1, createAbortSignal('msd-detail'));
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
@@ -202,7 +253,7 @@ function exportCsv() {
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `/api/mid-section-defect/export?${params}`;
|
||||
link.href = `/api/mid-section-defect/export?${params.toString()}`;
|
||||
link.download = `mid_section_defect_${snapshot.startDate}_to_${snapshot.endDate}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
@@ -211,7 +262,10 @@ function exportCsv() {
|
||||
|
||||
let autoRefreshStarted = false;
|
||||
const { createAbortSignal, startAutoRefresh } = useAutoRefresh({
|
||||
onRefresh: () => loadAnalysis(),
|
||||
onRefresh: async () => {
|
||||
trace.abort();
|
||||
await loadAnalysis();
|
||||
},
|
||||
intervalMs: 5 * 60 * 1000,
|
||||
autoStart: false,
|
||||
refreshOnVisible: true,
|
||||
@@ -220,70 +274,87 @@ const { createAbortSignal, startAutoRefresh } = useAutoRefresh({
|
||||
function initPage() {
|
||||
setDefaultDates();
|
||||
snapshotFilters();
|
||||
loadLossReasons();
|
||||
void 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>
|
||||
|
||||
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"
|
||||
/>
|
||||
|
||||
<TraceProgressBar
|
||||
v-if="showTraceProgress"
|
||||
:current_stage="trace.current_stage.value"
|
||||
:completed_stages="trace.completed_stages.value"
|
||||
:stage_errors="trace.stage_errors"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<div v-if="showAnalysisSkeleton" class="trace-skeleton-section">
|
||||
<div class="trace-skeleton-kpi-grid">
|
||||
<div v-for="index in 6" :key="`kpi-${index}`" class="trace-skeleton-card trace-skeleton-pulse"></div>
|
||||
</div>
|
||||
<div class="trace-skeleton-chart-grid">
|
||||
<div v-for="index in 6" :key="`chart-${index}`" class="trace-skeleton-chart trace-skeleton-pulse"></div>
|
||||
<div class="trace-skeleton-chart trace-skeleton-trend trace-skeleton-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="trace-fade">
|
||||
<div v-if="showAnalysisCharts">
|
||||
<KpiCards :kpi="analysisData.kpi" :loading="false" />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -456,43 +456,79 @@ body {
|
||||
}
|
||||
|
||||
/* ====== Empty State ====== */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
color: var(--msd-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ====== Loading Overlay ====== */
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
z-index: 999;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.loading-overlay.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top-color: var(--msd-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
color: var(--msd-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ====== Trace Skeleton ====== */
|
||||
.trace-skeleton-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.trace-skeleton-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.trace-skeleton-card {
|
||||
min-height: 100px;
|
||||
border-radius: 10px;
|
||||
background: #e5eaf2;
|
||||
box-shadow: var(--msd-shadow);
|
||||
}
|
||||
|
||||
.trace-skeleton-chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.trace-skeleton-chart {
|
||||
min-height: 300px;
|
||||
border-radius: 10px;
|
||||
background: #e5eaf2;
|
||||
box-shadow: var(--msd-shadow);
|
||||
}
|
||||
|
||||
.trace-skeleton-trend {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.trace-skeleton-pulse {
|
||||
animation: trace-skeleton-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.trace-fade-enter-active,
|
||||
.trace-fade-leave-active {
|
||||
transition: opacity 0.3s ease-in;
|
||||
}
|
||||
|
||||
.trace-fade-enter-from,
|
||||
.trace-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes trace-skeleton-pulse {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.trace-skeleton-chart-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import MultiSelect from '../resource-shared/components/MultiSelect.vue';
|
||||
import FilterToolbar from '../shared-ui/components/FilterToolbar.vue';
|
||||
@@ -20,12 +20,15 @@ const {
|
||||
resetEquipmentDateRange,
|
||||
bootstrap,
|
||||
resolveLots,
|
||||
loadLotLineage,
|
||||
loadLotHistory,
|
||||
loadAssociations,
|
||||
queryEquipmentPeriod,
|
||||
exportCurrentCsv,
|
||||
} = useQueryToolData();
|
||||
|
||||
const expandedLineageIds = ref(new Set());
|
||||
|
||||
const equipmentOptions = computed(() =>
|
||||
equipment.options.map((item) => ({
|
||||
value: item.RESOURCEID,
|
||||
@@ -47,6 +50,50 @@ function formatCell(value) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function rowContainerId(row) {
|
||||
return String(row?.container_id || row?.CONTAINERID || '').trim();
|
||||
}
|
||||
|
||||
function isLineageExpanded(containerId) {
|
||||
return expandedLineageIds.value.has(containerId);
|
||||
}
|
||||
|
||||
function lineageState(containerId) {
|
||||
return batch.lineageCache[containerId] || null;
|
||||
}
|
||||
|
||||
function lineageAncestors(containerId) {
|
||||
const state = lineageState(containerId);
|
||||
const values = state?.ancestors?.[containerId];
|
||||
if (!Array.isArray(values)) {
|
||||
return [];
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
async function handleResolveLots() {
|
||||
expandedLineageIds.value = new Set();
|
||||
await resolveLots();
|
||||
}
|
||||
|
||||
function toggleLotLineage(row) {
|
||||
const containerId = rowContainerId(row);
|
||||
if (!containerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Set(expandedLineageIds.value);
|
||||
if (next.has(containerId)) {
|
||||
next.delete(containerId);
|
||||
expandedLineageIds.value = next;
|
||||
return;
|
||||
}
|
||||
|
||||
next.add(containerId);
|
||||
expandedLineageIds.value = next;
|
||||
void loadLotLineage(containerId);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
hydrateFromUrl();
|
||||
if (!equipment.startDate || !equipment.endDate) {
|
||||
@@ -89,7 +136,7 @@ onMounted(async () => {
|
||||
/>
|
||||
</label>
|
||||
<template #actions>
|
||||
<button type="button" class="query-tool-btn query-tool-btn-primary" :disabled="loading.resolving" @click="resolveLots">
|
||||
<button type="button" class="query-tool-btn query-tool-btn-primary" :disabled="loading.resolving" @click="handleResolveLots">
|
||||
{{ loading.resolving ? '解析中...' : '解析' }}
|
||||
</button>
|
||||
</template>
|
||||
@@ -110,22 +157,60 @@ onMounted(async () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
<template
|
||||
v-for="(row, index) in batch.resolvedLots"
|
||||
:key="row.container_id || row.CONTAINERID || index"
|
||||
:class="{ selected: batch.selectedContainerId === (row.container_id || row.CONTAINERID) }"
|
||||
>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="query-tool-btn query-tool-btn-ghost"
|
||||
@click="loadLotHistory(row.container_id || row.CONTAINERID)"
|
||||
>
|
||||
載入歷程
|
||||
</button>
|
||||
</td>
|
||||
<td v-for="column in resolvedColumns" :key="column">{{ formatCell(row[column]) }}</td>
|
||||
</tr>
|
||||
<tr :class="{ selected: batch.selectedContainerId === (row.container_id || row.CONTAINERID) }">
|
||||
<td>
|
||||
<div class="query-tool-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="query-tool-btn query-tool-btn-ghost"
|
||||
@click="loadLotHistory(rowContainerId(row))"
|
||||
>
|
||||
載入歷程
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="query-tool-btn query-tool-btn-ghost"
|
||||
@click="toggleLotLineage(row)"
|
||||
>
|
||||
{{ isLineageExpanded(rowContainerId(row)) ? '收合血緣' : '展開血緣' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td v-for="column in resolvedColumns" :key="column">{{ formatCell(row[column]) }}</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="isLineageExpanded(rowContainerId(row))"
|
||||
:key="`lineage-${row.container_id || row.CONTAINERID || index}`"
|
||||
class="query-tool-lineage-row"
|
||||
>
|
||||
<td :colspan="resolvedColumns.length + 1">
|
||||
<div v-if="lineageState(rowContainerId(row))?.loading" class="query-tool-empty">
|
||||
血緣追溯中...
|
||||
</div>
|
||||
<div v-else-if="lineageState(rowContainerId(row))?.error" class="query-tool-error-inline">
|
||||
{{ lineageState(rowContainerId(row)).error }}
|
||||
</div>
|
||||
<div v-else-if="lineageAncestors(rowContainerId(row)).length === 0" class="query-tool-empty">
|
||||
無上游血緣資料
|
||||
</div>
|
||||
<div v-else class="query-tool-lineage-content">
|
||||
<strong>上游節點 ({{ lineageAncestors(rowContainerId(row)).length }})</strong>
|
||||
<ul class="query-tool-lineage-list">
|
||||
<li
|
||||
v-for="ancestorId in lineageAncestors(rowContainerId(row))"
|
||||
:key="`${rowContainerId(row)}-${ancestorId}`"
|
||||
>
|
||||
{{ ancestorId }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -85,6 +85,7 @@ export function useQueryToolData() {
|
||||
lotHistoryRows: [],
|
||||
associationType: 'materials',
|
||||
associationRows: [],
|
||||
lineageCache: {},
|
||||
});
|
||||
|
||||
const equipment = reactive({
|
||||
@@ -185,6 +186,7 @@ export function useQueryToolData() {
|
||||
batch.selectedContainerId = '';
|
||||
batch.lotHistoryRows = [];
|
||||
batch.associationRows = [];
|
||||
batch.lineageCache = {};
|
||||
syncBatchUrlState();
|
||||
try {
|
||||
const payload = await apiPost(
|
||||
@@ -236,6 +238,52 @@ export function useQueryToolData() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLotLineage(containerId) {
|
||||
const id = String(containerId || '').trim();
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cached = batch.lineageCache[id];
|
||||
if (cached?.loading || cached?.ancestors) {
|
||||
return true;
|
||||
}
|
||||
|
||||
batch.lineageCache[id] = {
|
||||
ancestors: null,
|
||||
merges: null,
|
||||
loading: true,
|
||||
error: '',
|
||||
};
|
||||
|
||||
try {
|
||||
const payload = await apiPost(
|
||||
'/api/trace/lineage',
|
||||
{
|
||||
profile: 'query_tool',
|
||||
container_ids: [id],
|
||||
},
|
||||
{ timeout: 60000, silent: true },
|
||||
);
|
||||
|
||||
batch.lineageCache[id] = {
|
||||
ancestors: payload?.ancestors || {},
|
||||
merges: payload?.merges || {},
|
||||
loading: false,
|
||||
error: '',
|
||||
};
|
||||
return true;
|
||||
} catch (error) {
|
||||
batch.lineageCache[id] = {
|
||||
ancestors: null,
|
||||
merges: null,
|
||||
loading: false,
|
||||
error: error?.message || '血緣查詢失敗',
|
||||
};
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssociations() {
|
||||
if (!batch.selectedContainerId) {
|
||||
errorMessage.value = '請先選擇一筆 CONTAINERID';
|
||||
@@ -391,6 +439,7 @@ export function useQueryToolData() {
|
||||
resetEquipmentDateRange,
|
||||
bootstrap,
|
||||
resolveLots,
|
||||
loadLotLineage,
|
||||
loadLotHistory,
|
||||
loadAssociations,
|
||||
queryEquipmentPeriod,
|
||||
|
||||
@@ -80,6 +80,12 @@
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.query-tool-row-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.query-tool-table-wrap {
|
||||
margin-top: 12px;
|
||||
overflow: auto;
|
||||
@@ -112,6 +118,34 @@
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.query-tool-lineage-row td {
|
||||
background: #f8fafc;
|
||||
border-top: 1px dashed #dbeafe;
|
||||
}
|
||||
|
||||
.query-tool-lineage-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.query-tool-lineage-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.query-tool-error-inline {
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.query-tool-empty {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
|
||||
166
frontend/src/shared-composables/TraceProgressBar.vue
Normal file
166
frontend/src/shared-composables/TraceProgressBar.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
current_stage: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
completed_stages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
stage_errors: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const STAGES = Object.freeze([
|
||||
{ id: 'seed-resolve', key: 'seed', label: '批次解析' },
|
||||
{ id: 'lineage', key: 'lineage', label: '血緣追溯' },
|
||||
{ id: 'events', key: 'events', label: '事件查詢' },
|
||||
]);
|
||||
|
||||
const completedSet = computed(() => new Set(props.completed_stages || []));
|
||||
|
||||
function hasStageError(stage) {
|
||||
const error = props.stage_errors?.[stage.key];
|
||||
return Boolean(error?.message || error?.code);
|
||||
}
|
||||
|
||||
function stageState(stage) {
|
||||
if (hasStageError(stage)) {
|
||||
return 'error';
|
||||
}
|
||||
if (completedSet.value.has(stage.id)) {
|
||||
return 'complete';
|
||||
}
|
||||
if (props.current_stage === stage.id) {
|
||||
return 'active';
|
||||
}
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
const firstError = computed(() => {
|
||||
for (const stage of STAGES) {
|
||||
const error = props.stage_errors?.[stage.key];
|
||||
if (error?.message) {
|
||||
return `[${stage.label}] ${error.message}`;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="trace-progress">
|
||||
<div class="trace-progress-track">
|
||||
<div
|
||||
v-for="(stage, index) in STAGES"
|
||||
:key="stage.id"
|
||||
class="trace-progress-step"
|
||||
:class="`is-${stageState(stage)}`"
|
||||
>
|
||||
<div class="trace-progress-dot"></div>
|
||||
<span class="trace-progress-label">{{ stage.label }}</span>
|
||||
<div v-if="index < STAGES.length - 1" class="trace-progress-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="firstError" class="trace-progress-error">{{ firstError }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.trace-progress {
|
||||
margin: 12px 0 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 10px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.trace-progress-track {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.trace-progress-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.trace-progress-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trace-progress-label {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trace-progress-line {
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
opacity: 0.35;
|
||||
flex: 1;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.trace-progress-step.is-complete {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.trace-progress-step.is-active {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.trace-progress-step.is-active .trace-progress-dot {
|
||||
animation: trace-progress-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.trace-progress-step.is-error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.trace-progress-error {
|
||||
margin: 10px 0 0;
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@keyframes trace-progress-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.35);
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.trace-progress-track {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.trace-progress-line {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,3 +2,4 @@ export { useAutoRefresh } from './useAutoRefresh.js';
|
||||
export { useAutocomplete } from './useAutocomplete.js';
|
||||
export { usePaginationState } from './usePaginationState.js';
|
||||
export { readQueryState, writeQueryState } from './useQueryState.js';
|
||||
export { useTraceProgress } from './useTraceProgress.js';
|
||||
|
||||
181
frontend/src/shared-composables/useTraceProgress.js
Normal file
181
frontend/src/shared-composables/useTraceProgress.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
import { apiPost, ensureMesApiAvailable } from '../core/api.js';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const DEFAULT_STAGE_TIMEOUT_MS = 60000;
|
||||
const PROFILE_DOMAINS = Object.freeze({
|
||||
query_tool: ['history', 'materials', 'rejects', 'holds', 'jobs'],
|
||||
mid_section_defect: ['upstream_history'],
|
||||
});
|
||||
|
||||
function stageKey(stageName) {
|
||||
if (stageName === 'seed-resolve') return 'seed';
|
||||
if (stageName === 'lineage') return 'lineage';
|
||||
return 'events';
|
||||
}
|
||||
|
||||
function normalizeSeedContainerIds(seedPayload) {
|
||||
const rows = Array.isArray(seedPayload?.seeds) ? seedPayload.seeds : [];
|
||||
const seen = new Set();
|
||||
const containerIds = [];
|
||||
rows.forEach((row) => {
|
||||
const id = String(row?.container_id || '').trim();
|
||||
if (!id || seen.has(id)) {
|
||||
return;
|
||||
}
|
||||
seen.add(id);
|
||||
containerIds.push(id);
|
||||
});
|
||||
return containerIds;
|
||||
}
|
||||
|
||||
function collectAllContainerIds(seedContainerIds, lineagePayload) {
|
||||
const seen = new Set(seedContainerIds);
|
||||
const merged = [...seedContainerIds];
|
||||
const ancestors = lineagePayload?.ancestors || {};
|
||||
Object.values(ancestors).forEach((values) => {
|
||||
if (!Array.isArray(values)) {
|
||||
return;
|
||||
}
|
||||
values.forEach((value) => {
|
||||
const id = String(value || '').trim();
|
||||
if (!id || seen.has(id)) {
|
||||
return;
|
||||
}
|
||||
seen.add(id);
|
||||
merged.push(id);
|
||||
});
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function useTraceProgress({ profile } = {}) {
|
||||
const current_stage = ref(null);
|
||||
const completed_stages = ref([]);
|
||||
const is_running = ref(false);
|
||||
|
||||
const stage_results = reactive({
|
||||
seed: null,
|
||||
lineage: null,
|
||||
events: null,
|
||||
});
|
||||
|
||||
const stage_errors = reactive({
|
||||
seed: null,
|
||||
lineage: null,
|
||||
events: null,
|
||||
});
|
||||
|
||||
let activeController = null;
|
||||
|
||||
function reset() {
|
||||
completed_stages.value = [];
|
||||
current_stage.value = null;
|
||||
stage_results.seed = null;
|
||||
stage_results.lineage = null;
|
||||
stage_results.events = null;
|
||||
stage_errors.seed = null;
|
||||
stage_errors.lineage = null;
|
||||
stage_errors.events = null;
|
||||
}
|
||||
|
||||
function abort() {
|
||||
if (activeController) {
|
||||
activeController.abort();
|
||||
activeController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(params = {}) {
|
||||
const domains = PROFILE_DOMAINS[profile];
|
||||
if (!domains) {
|
||||
throw new Error(`Unsupported trace profile: ${profile}`);
|
||||
}
|
||||
|
||||
abort();
|
||||
reset();
|
||||
is_running.value = true;
|
||||
|
||||
const controller = new AbortController();
|
||||
activeController = controller;
|
||||
|
||||
try {
|
||||
current_stage.value = 'seed-resolve';
|
||||
const seedPayload = await apiPost(
|
||||
'/api/trace/seed-resolve',
|
||||
{ profile, params },
|
||||
{ timeout: DEFAULT_STAGE_TIMEOUT_MS, signal: controller.signal },
|
||||
);
|
||||
stage_results.seed = seedPayload;
|
||||
completed_stages.value = [...completed_stages.value, 'seed-resolve'];
|
||||
|
||||
const seedContainerIds = normalizeSeedContainerIds(seedPayload);
|
||||
if (seedContainerIds.length === 0) {
|
||||
return stage_results;
|
||||
}
|
||||
|
||||
current_stage.value = 'lineage';
|
||||
const lineagePayload = await apiPost(
|
||||
'/api/trace/lineage',
|
||||
{
|
||||
profile,
|
||||
container_ids: seedContainerIds,
|
||||
cache_key: seedPayload?.cache_key || null,
|
||||
},
|
||||
{ timeout: DEFAULT_STAGE_TIMEOUT_MS, signal: controller.signal },
|
||||
);
|
||||
stage_results.lineage = lineagePayload;
|
||||
completed_stages.value = [...completed_stages.value, 'lineage'];
|
||||
|
||||
const allContainerIds = collectAllContainerIds(seedContainerIds, lineagePayload);
|
||||
current_stage.value = 'events';
|
||||
const eventsPayload = await apiPost(
|
||||
'/api/trace/events',
|
||||
{
|
||||
profile,
|
||||
container_ids: allContainerIds,
|
||||
domains,
|
||||
cache_key: seedPayload?.cache_key || null,
|
||||
params,
|
||||
seed_container_ids: seedContainerIds,
|
||||
lineage: {
|
||||
ancestors: lineagePayload?.ancestors || {},
|
||||
},
|
||||
},
|
||||
{ timeout: DEFAULT_STAGE_TIMEOUT_MS, signal: controller.signal },
|
||||
);
|
||||
stage_results.events = eventsPayload;
|
||||
completed_stages.value = [...completed_stages.value, 'events'];
|
||||
return stage_results;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
return stage_results;
|
||||
}
|
||||
const key = stageKey(current_stage.value);
|
||||
stage_errors[key] = {
|
||||
code: error?.errorCode || null,
|
||||
message: error?.message || '追溯查詢失敗',
|
||||
};
|
||||
return stage_results;
|
||||
} finally {
|
||||
if (activeController === controller) {
|
||||
activeController = null;
|
||||
}
|
||||
current_stage.value = null;
|
||||
is_running.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
current_stage,
|
||||
completed_stages,
|
||||
stage_results,
|
||||
stage_errors,
|
||||
is_running,
|
||||
execute,
|
||||
reset,
|
||||
abort,
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['drilldown']);
|
||||
|
||||
const workcenters = computed(() => props.data?.workcenters || []);
|
||||
const packages = computed(() => (props.data?.packages || []).slice(0, 15));
|
||||
const packages = computed(() => props.data?.packages || []);
|
||||
|
||||
function formatNumber(value) {
|
||||
if (!value) {
|
||||
|
||||
Reference in New Issue
Block a user