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:
egg
2026-02-12 16:30:24 +08:00
parent c38b5f646a
commit 519f8ae2f4
52 changed files with 5074 additions and 4047 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View 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,
};
}

View File

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