feat(mid-section-defect): full-line bidirectional defect trace center with dual query mode
Transform /mid-section-defect from TMTT-only backward analysis into a full-line bidirectional defect traceability center supporting all detection stations. Key changes: - Parameterized station detection: any workcenter group as detection station - Bidirectional tracing: backward (upstream attribution) + forward (downstream reject rates) - Dual query mode: date range OR LOT/工單/WAFER container-based seed resolution - Multi-select filters for upstream station, equipment model (RESOURCEFAMILYNAME), and loss reasons - Progressive 3-stage trace pipeline (seed-resolve → lineage → events) with streaming UI - Equipment model lookup via resource cache instead of SPECNAME - Session caching, auto-refresh, searchable MultiSelect with fuzzy matching - Remove legacy tmtt-defect module (fully superseded) - Archive openspec change artifacts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import TraceProgressBar from '../shared-composables/TraceProgressBar.vue';
|
||||
|
||||
import FilterBar from './components/FilterBar.vue';
|
||||
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 DetailTable from './components/DetailTable.vue';
|
||||
@@ -16,18 +17,84 @@ ensureMesApiAvailable();
|
||||
|
||||
const API_TIMEOUT = 120000;
|
||||
const PAGE_SIZE = 200;
|
||||
const SESSION_CACHE_KEY = 'msd:cache';
|
||||
const SESSION_CACHE_TTL = 5 * 60 * 1000; // 5 min, matches backend Redis TTL
|
||||
const CHART_TOP_N = 10;
|
||||
|
||||
function buildMachineChartFromAttribution(records) {
|
||||
if (!records || records.length === 0) return [];
|
||||
const agg = {};
|
||||
for (const rec of records) {
|
||||
const key = rec.EQUIPMENT_NAME || '(未知)';
|
||||
if (!agg[key]) agg[key] = { input_qty: 0, defect_qty: 0, lot_count: 0 };
|
||||
agg[key].input_qty += rec.INPUT_QTY;
|
||||
agg[key].defect_qty += rec.DEFECT_QTY;
|
||||
agg[key].lot_count += rec.DETECTION_LOT_COUNT;
|
||||
}
|
||||
const sorted = Object.entries(agg).sort((a, b) => b[1].defect_qty - a[1].defect_qty);
|
||||
const items = [];
|
||||
const other = { input_qty: 0, defect_qty: 0, lot_count: 0 };
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const [name, data] = sorted[i];
|
||||
if (i < CHART_TOP_N) {
|
||||
const rate = data.input_qty > 0 ? Math.round((data.defect_qty / data.input_qty) * 1e6) / 1e4 : 0;
|
||||
items.push({ name, input_qty: data.input_qty, defect_qty: data.defect_qty, defect_rate: rate, lot_count: data.lot_count });
|
||||
} else {
|
||||
other.input_qty += data.input_qty;
|
||||
other.defect_qty += data.defect_qty;
|
||||
other.lot_count += data.lot_count;
|
||||
}
|
||||
}
|
||||
if (other.defect_qty > 0 || other.input_qty > 0) {
|
||||
const rate = other.input_qty > 0 ? Math.round((other.defect_qty / other.input_qty) * 1e6) / 1e4 : 0;
|
||||
items.push({ name: '其他', ...other, defect_rate: rate });
|
||||
}
|
||||
const totalDefects = items.reduce((s, d) => s + d.defect_qty, 0);
|
||||
let cumsum = 0;
|
||||
for (const item of items) {
|
||||
cumsum += item.defect_qty;
|
||||
item.cumulative_pct = totalDefects > 0 ? Math.round((cumsum / totalDefects) * 1e4) / 100 : 0;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const stationOptions = ref([]);
|
||||
(async () => {
|
||||
try {
|
||||
const result = await apiGet('/api/mid-section-defect/station-options');
|
||||
if (result?.success && Array.isArray(result.data)) {
|
||||
stationOptions.value = result.data;
|
||||
}
|
||||
} catch { /* non-blocking */ }
|
||||
})();
|
||||
const stationLabelMap = computed(() => {
|
||||
const m = {};
|
||||
for (const opt of stationOptions.value) {
|
||||
m[opt.name] = opt.label || opt.name;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
const filters = reactive({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
lossReasons: [],
|
||||
station: '測試',
|
||||
direction: 'backward',
|
||||
});
|
||||
const committedFilters = ref({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
lossReasons: [],
|
||||
station: '測試',
|
||||
direction: 'backward',
|
||||
});
|
||||
|
||||
const queryMode = ref('date_range');
|
||||
const containerInputType = ref('lot');
|
||||
const containerInput = ref('');
|
||||
const resolutionInfo = ref(null);
|
||||
|
||||
const availableLossReasons = ref([]);
|
||||
const trace = useTraceProgress({ profile: 'mid_section_defect' });
|
||||
|
||||
@@ -54,6 +121,69 @@ const loading = reactive({
|
||||
|
||||
const hasQueried = ref(false);
|
||||
const queryError = ref('');
|
||||
const restoredFromCache = ref(false);
|
||||
|
||||
const upstreamStationFilter = ref([]);
|
||||
const upstreamSpecFilter = ref([]);
|
||||
const upstreamStationOptions = computed(() => {
|
||||
const attribution = analysisData.value?.attribution;
|
||||
if (!Array.isArray(attribution) || attribution.length === 0) return [];
|
||||
const seen = new Set();
|
||||
const options = [];
|
||||
for (const rec of attribution) {
|
||||
const group = rec.WORKCENTER_GROUP;
|
||||
if (group && !seen.has(group)) {
|
||||
seen.add(group);
|
||||
options.push({ value: group, label: stationLabelMap.value[group] || group });
|
||||
}
|
||||
}
|
||||
return options.sort((a, b) => a.label.localeCompare(b.label, 'zh-TW'));
|
||||
});
|
||||
const upstreamSpecOptions = computed(() => {
|
||||
const attribution = analysisData.value?.attribution;
|
||||
if (!Array.isArray(attribution) || attribution.length === 0) return [];
|
||||
// Apply station filter first so spec options are contextual
|
||||
const base = upstreamStationFilter.value.length > 0
|
||||
? attribution.filter(rec => upstreamStationFilter.value.includes(rec.WORKCENTER_GROUP))
|
||||
: attribution;
|
||||
const seen = new Set();
|
||||
const options = [];
|
||||
for (const rec of base) {
|
||||
const family = rec.RESOURCEFAMILYNAME;
|
||||
if (family && family !== '(未知)' && !seen.has(family)) {
|
||||
seen.add(family);
|
||||
options.push(family);
|
||||
}
|
||||
}
|
||||
return options.sort((a, b) => a.localeCompare(b, 'zh-TW'));
|
||||
});
|
||||
const filteredByMachineData = computed(() => {
|
||||
const attribution = analysisData.value?.attribution;
|
||||
const hasFilter = upstreamStationFilter.value.length > 0 || upstreamSpecFilter.value.length > 0;
|
||||
if (!hasFilter || !Array.isArray(attribution) || attribution.length === 0) {
|
||||
return analysisData.value?.charts?.by_machine ?? [];
|
||||
}
|
||||
const filtered = attribution.filter(rec => {
|
||||
if (upstreamStationFilter.value.length > 0 && !upstreamStationFilter.value.includes(rec.WORKCENTER_GROUP)) return false;
|
||||
if (upstreamSpecFilter.value.length > 0 && !upstreamSpecFilter.value.includes(rec.RESOURCEFAMILYNAME)) return false;
|
||||
return true;
|
||||
});
|
||||
return filtered.length > 0 ? buildMachineChartFromAttribution(filtered) : [];
|
||||
});
|
||||
|
||||
const isForward = computed(() => committedFilters.value.direction === 'forward');
|
||||
const committedStation = computed(() => {
|
||||
const key = committedFilters.value.station || '測試';
|
||||
return stationLabelMap.value[key] || key;
|
||||
});
|
||||
|
||||
const headerSubtitle = computed(() => {
|
||||
const station = committedStation.value;
|
||||
if (isForward.value) {
|
||||
return `${station}站不良批次 → 追蹤倖存批次下游表現`;
|
||||
}
|
||||
return `${station}站不良 → 回溯上游機台歸因`;
|
||||
});
|
||||
|
||||
const hasTraceError = computed(() => (
|
||||
Boolean(trace.stage_errors.seed)
|
||||
@@ -67,7 +197,9 @@ const showTraceProgress = computed(() => (
|
||||
));
|
||||
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));
|
||||
const showAnalysisCharts = computed(() => hasQueried.value && (Boolean(eventsAggregation.value) || restoredFromCache.value));
|
||||
|
||||
const skeletonChartCount = computed(() => (isForward.value ? 4 : 6));
|
||||
|
||||
function emptyAnalysisData() {
|
||||
return {
|
||||
@@ -105,12 +237,20 @@ function unwrapApiResult(result, fallbackMessage) {
|
||||
function buildFilterParams() {
|
||||
const snapshot = committedFilters.value;
|
||||
const params = {
|
||||
start_date: snapshot.startDate,
|
||||
end_date: snapshot.endDate,
|
||||
station: snapshot.station,
|
||||
direction: snapshot.direction,
|
||||
};
|
||||
if (snapshot.lossReasons.length) {
|
||||
params.loss_reasons = snapshot.lossReasons;
|
||||
}
|
||||
if (snapshot.queryMode === 'container') {
|
||||
params.mode = 'container';
|
||||
params.resolve_type = snapshot.containerInputType === 'lot' ? 'lot_id' : snapshot.containerInputType;
|
||||
params.values = snapshot.containerValues;
|
||||
} else {
|
||||
params.start_date = snapshot.startDate;
|
||||
params.end_date = snapshot.endDate;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -119,6 +259,8 @@ function buildDetailParams() {
|
||||
const params = {
|
||||
start_date: snapshot.startDate,
|
||||
end_date: snapshot.endDate,
|
||||
station: snapshot.station,
|
||||
direction: snapshot.direction,
|
||||
};
|
||||
if (snapshot.lossReasons.length) {
|
||||
params.loss_reasons = snapshot.lossReasons.join(',');
|
||||
@@ -126,11 +268,23 @@ function buildDetailParams() {
|
||||
return params;
|
||||
}
|
||||
|
||||
function parseContainerValues() {
|
||||
return containerInput.value
|
||||
.split(/[\n,;]+/)
|
||||
.map((v) => v.trim().replace(/\*/g, '%'))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function snapshotFilters() {
|
||||
committedFilters.value = {
|
||||
startDate: filters.startDate,
|
||||
endDate: filters.endDate,
|
||||
lossReasons: [...filters.lossReasons],
|
||||
station: filters.station,
|
||||
direction: filters.direction,
|
||||
queryMode: queryMode.value,
|
||||
containerInputType: containerInputType.value,
|
||||
containerValues: parseContainerValues(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,16 +337,30 @@ async function loadDetail(page = 1, signal = null) {
|
||||
|
||||
async function loadAnalysis() {
|
||||
queryError.value = '';
|
||||
restoredFromCache.value = false;
|
||||
resolutionInfo.value = null;
|
||||
upstreamStationFilter.value = [];
|
||||
upstreamSpecFilter.value = [];
|
||||
trace.abort();
|
||||
trace.reset();
|
||||
loading.querying = true;
|
||||
hasQueried.value = true;
|
||||
analysisData.value = emptyAnalysisData();
|
||||
|
||||
const isContainerMode = committedFilters.value.queryMode === 'container';
|
||||
|
||||
try {
|
||||
const params = buildFilterParams();
|
||||
await trace.execute(params);
|
||||
|
||||
// Extract resolution info for container mode
|
||||
if (isContainerMode && trace.stage_results.seed) {
|
||||
resolutionInfo.value = {
|
||||
resolved_count: trace.stage_results.seed.seed_count || 0,
|
||||
not_found: trace.stage_results.seed.not_found || [],
|
||||
};
|
||||
}
|
||||
|
||||
if (eventsAggregation.value) {
|
||||
analysisData.value = {
|
||||
...analysisData.value,
|
||||
@@ -206,7 +374,11 @@ async function loadAnalysis() {
|
||||
}
|
||||
|
||||
if (!stageError || trace.completed_stages.value.includes('events')) {
|
||||
await loadDetail(1, createAbortSignal('msd-detail'));
|
||||
// Container mode: no detail/export (no date range for legacy API)
|
||||
if (!isContainerMode) {
|
||||
await loadDetail(1, createAbortSignal('msd-detail'));
|
||||
}
|
||||
saveSession();
|
||||
}
|
||||
|
||||
if (!autoRefreshStarted) {
|
||||
@@ -247,6 +419,8 @@ function exportCsv() {
|
||||
const params = new URLSearchParams({
|
||||
start_date: snapshot.startDate,
|
||||
end_date: snapshot.endDate,
|
||||
station: snapshot.station,
|
||||
direction: snapshot.direction,
|
||||
});
|
||||
if (snapshot.lossReasons.length) {
|
||||
params.set('loss_reasons', snapshot.lossReasons.join(','));
|
||||
@@ -254,7 +428,7 @@ function exportCsv() {
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `/api/mid-section-defect/export?${params.toString()}`;
|
||||
link.download = `mid_section_defect_${snapshot.startDate}_to_${snapshot.endDate}.csv`;
|
||||
link.download = `mid_section_defect_${snapshot.station}_${snapshot.direction}_${snapshot.startDate}_to_${snapshot.endDate}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
@@ -271,7 +445,53 @@ const { createAbortSignal, startAutoRefresh } = useAutoRefresh({
|
||||
refreshOnVisible: true,
|
||||
});
|
||||
|
||||
function saveSession() {
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_CACHE_KEY, JSON.stringify({
|
||||
ts: Date.now(),
|
||||
committedFilters: committedFilters.value,
|
||||
filters: { ...filters },
|
||||
analysisData: analysisData.value,
|
||||
detailData: detailData.value,
|
||||
detailPagination: detailPagination.value,
|
||||
availableLossReasons: availableLossReasons.value,
|
||||
queryMode: queryMode.value,
|
||||
containerInputType: containerInputType.value,
|
||||
containerInput: containerInput.value,
|
||||
resolutionInfo: resolutionInfo.value,
|
||||
}));
|
||||
} catch { /* quota exceeded or unavailable */ }
|
||||
}
|
||||
|
||||
function restoreSession() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_CACHE_KEY);
|
||||
if (!raw) return false;
|
||||
const data = JSON.parse(raw);
|
||||
if (Date.now() - data.ts > SESSION_CACHE_TTL) {
|
||||
sessionStorage.removeItem(SESSION_CACHE_KEY);
|
||||
return false;
|
||||
}
|
||||
Object.assign(filters, data.filters);
|
||||
committedFilters.value = data.committedFilters;
|
||||
analysisData.value = data.analysisData;
|
||||
detailData.value = data.detailData || [];
|
||||
detailPagination.value = data.detailPagination || { page: 1, page_size: PAGE_SIZE, total_count: 0, total_pages: 1 };
|
||||
availableLossReasons.value = data.availableLossReasons || [];
|
||||
queryMode.value = data.queryMode || 'date_range';
|
||||
containerInputType.value = data.containerInputType || 'lot';
|
||||
containerInput.value = data.containerInput || '';
|
||||
resolutionInfo.value = data.resolutionInfo || null;
|
||||
hasQueried.value = true;
|
||||
restoredFromCache.value = true;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function initPage() {
|
||||
if (restoreSession()) return;
|
||||
setDefaultDates();
|
||||
snapshotFilters();
|
||||
void loadLossReasons();
|
||||
@@ -283,16 +503,24 @@ void initPage();
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-header">
|
||||
<h1>中段製程不良追溯分析</h1>
|
||||
<p class="header-desc">TMTT 測試站不良回溯至上游機台 / 站點 / 製程</p>
|
||||
<h1>製程不良追溯分析</h1>
|
||||
<p class="header-desc">{{ headerSubtitle }}</p>
|
||||
</header>
|
||||
|
||||
<FilterBar
|
||||
:filters="filters"
|
||||
:loading="loading.querying"
|
||||
:available-loss-reasons="availableLossReasons"
|
||||
:station-options="stationOptions"
|
||||
:query-mode="queryMode"
|
||||
:container-input-type="containerInputType"
|
||||
:container-input="containerInput"
|
||||
:resolution-info="resolutionInfo"
|
||||
@update-filters="handleUpdateFilters"
|
||||
@query="handleQuery"
|
||||
@update:query-mode="queryMode = $event"
|
||||
@update:container-input-type="containerInputType = $event"
|
||||
@update:container-input="containerInput = $event"
|
||||
/>
|
||||
|
||||
<TraceProgressBar
|
||||
@@ -306,7 +534,7 @@ void initPage();
|
||||
|
||||
<template v-if="hasQueried">
|
||||
<div v-if="analysisData.genealogy_status === 'error'" class="warning-banner">
|
||||
追溯分析未完成(genealogy 查詢失敗),圖表僅顯示 TMTT 站點數據。
|
||||
追溯分析未完成(genealogy 查詢失敗),圖表僅顯示偵測站數據。
|
||||
</div>
|
||||
|
||||
<div v-if="showAnalysisSkeleton" class="trace-skeleton-section">
|
||||
@@ -314,29 +542,65 @@ void initPage();
|
||||
<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 v-for="index in skeletonChartCount" :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" />
|
||||
<KpiCards
|
||||
:kpi="analysisData.kpi"
|
||||
:loading="false"
|
||||
:direction="committedFilters.direction"
|
||||
:station-label="committedStation"
|
||||
/>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<div class="charts-row">
|
||||
<ParetoChart title="依下游站點" :data="analysisData.charts?.by_downstream_station" />
|
||||
<ParetoChart title="依下游不良原因" :data="analysisData.charts?.by_downstream_loss_reason" />
|
||||
</div>
|
||||
<div class="charts-row">
|
||||
<ParetoChart title="依下游機台" :data="analysisData.charts?.by_downstream_machine" />
|
||||
<ParetoChart title="依偵測機台" :data="analysisData.charts?.by_detection_machine" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="committedFilters.queryMode !== 'container'" class="charts-row charts-row-full">
|
||||
<TrendChart :data="analysisData.daily_trend" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,9 +608,11 @@ void initPage();
|
||||
</transition>
|
||||
|
||||
<DetailTable
|
||||
v-if="committedFilters.queryMode !== 'container'"
|
||||
:data="detailData"
|
||||
:loading="detailLoading"
|
||||
:pagination="detailPagination"
|
||||
:direction="committedFilters.direction"
|
||||
@export-csv="exportCsv"
|
||||
@prev-page="prevPage"
|
||||
@next-page="nextPage"
|
||||
@@ -354,7 +620,7 @@ void initPage();
|
||||
</template>
|
||||
|
||||
<div v-else-if="!loading.querying" class="empty-state">
|
||||
<p>請選擇日期範圍與不良原因,點擊「查詢」開始分析。</p>
|
||||
<p>請選擇偵測站與查詢條件,點擊「查詢」開始分析。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -16,6 +16,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({ page: 1, page_size: 200, total_count: 0, total_pages: 1 }),
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'backward',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['export-csv', 'prev-page', 'next-page']);
|
||||
@@ -23,12 +27,12 @@ const emit = defineEmits(['export-csv', 'prev-page', 'next-page']);
|
||||
const sortField = ref('DEFECT_RATE');
|
||||
const sortAsc = ref(false);
|
||||
|
||||
const COLUMNS = [
|
||||
const COLUMNS_BACKWARD = [
|
||||
{ key: 'CONTAINERNAME', label: 'LOT ID', width: '140px' },
|
||||
{ key: 'PJ_TYPE', label: 'TYPE', width: '80px' },
|
||||
{ key: 'PRODUCTLINENAME', label: 'PACKAGE', width: '90px' },
|
||||
{ key: 'WORKFLOW', label: 'WORKFLOW', width: '100px' },
|
||||
{ key: 'TMTT_EQUIPMENTNAME', label: 'TMTT 設備', width: '110px' },
|
||||
{ key: 'DETECTION_EQUIPMENTNAME', label: '偵測設備', width: '110px' },
|
||||
{ key: 'INPUT_QTY', label: '投入數', width: '70px', numeric: true },
|
||||
{ key: 'LOSS_REASON', label: '不良原因', width: '130px' },
|
||||
{ key: 'DEFECT_QTY', label: '不良數', width: '70px', numeric: true },
|
||||
@@ -37,6 +41,21 @@ const COLUMNS = [
|
||||
{ key: 'UPSTREAM_MACHINES', label: '上游機台', width: '200px' },
|
||||
];
|
||||
|
||||
const COLUMNS_FORWARD = [
|
||||
{ key: 'CONTAINERNAME', label: 'LOT ID', width: '140px' },
|
||||
{ key: 'DETECTION_EQUIPMENTNAME', label: '偵測設備', width: '120px' },
|
||||
{ key: 'TRACKINQTY', label: '偵測投入', width: '80px', numeric: true },
|
||||
{ key: 'DEFECT_QTY', label: '偵測不良', width: '80px', numeric: true },
|
||||
{ key: 'DOWNSTREAM_STATIONS_REACHED', label: '下游到達站數', width: '100px', numeric: true },
|
||||
{ key: 'DOWNSTREAM_TOTAL_REJECT', label: '下游不良總數', width: '100px', numeric: true },
|
||||
{ key: 'DOWNSTREAM_REJECT_RATE', label: '下游不良率(%)', width: '110px', numeric: true },
|
||||
{ key: 'WORST_DOWNSTREAM_STATION', label: '最差下游站', width: '120px' },
|
||||
];
|
||||
|
||||
const activeColumns = computed(() => (
|
||||
props.direction === 'forward' ? COLUMNS_FORWARD : COLUMNS_BACKWARD
|
||||
));
|
||||
|
||||
const sortedData = computed(() => {
|
||||
if (!props.data || !props.data.length) return [];
|
||||
const field = sortField.value;
|
||||
@@ -80,7 +99,7 @@ function sortIcon(field) {
|
||||
|
||||
function formatCell(value, col) {
|
||||
if (value == null || value === '') return '-';
|
||||
if (col.key === 'DEFECT_RATE') return Number(value).toFixed(2);
|
||||
if (col.key === 'DEFECT_RATE' || col.key === 'DOWNSTREAM_REJECT_RATE') return Number(value).toFixed(2);
|
||||
if (col.numeric) return Number(value).toLocaleString();
|
||||
return value;
|
||||
}
|
||||
@@ -104,7 +123,7 @@ function formatCell(value, col) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in COLUMNS"
|
||||
v-for="col in activeColumns"
|
||||
:key="col.key"
|
||||
:style="{ width: col.width }"
|
||||
class="sortable"
|
||||
@@ -116,12 +135,12 @@ function formatCell(value, col) {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in sortedData" :key="idx">
|
||||
<td v-for="col in COLUMNS" :key="col.key" :class="{ numeric: col.numeric }">
|
||||
<td v-for="col in activeColumns" :key="col.key" :class="{ numeric: col.numeric }">
|
||||
{{ formatCell(row[col.key], col) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!sortedData.length">
|
||||
<td :colspan="COLUMNS.length" class="empty-row">暫無資料</td>
|
||||
<td :colspan="activeColumns.length" class="empty-row">暫無資料</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -14,9 +14,35 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
stationOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
queryMode: {
|
||||
type: String,
|
||||
default: 'date_range',
|
||||
},
|
||||
containerInputType: {
|
||||
type: String,
|
||||
default: 'lot',
|
||||
},
|
||||
containerInput: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
resolutionInfo: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update-filters', 'query']);
|
||||
const emit = defineEmits([
|
||||
'update-filters',
|
||||
'query',
|
||||
'update:queryMode',
|
||||
'update:containerInputType',
|
||||
'update:containerInput',
|
||||
]);
|
||||
|
||||
function updateFilters(patch) {
|
||||
emit('update-filters', {
|
||||
@@ -29,29 +55,112 @@ function updateFilters(patch) {
|
||||
<template>
|
||||
<section class="section-card">
|
||||
<div class="section-inner">
|
||||
<!-- Mode toggle tabs -->
|
||||
<div class="mode-tab-row">
|
||||
<button
|
||||
type="button"
|
||||
:class="['mode-tab', { active: queryMode === 'date_range' }]"
|
||||
@click="$emit('update:queryMode', 'date_range')"
|
||||
>
|
||||
日期區間
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['mode-tab', { active: queryMode === 'container' }]"
|
||||
@click="$emit('update:queryMode', 'container')"
|
||||
>
|
||||
LOT / 工單 / WAFER
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<!-- Shared: detection station -->
|
||||
<div class="filter-field">
|
||||
<label for="msd-start-date">開始</label>
|
||||
<input
|
||||
id="msd-start-date"
|
||||
type="date"
|
||||
:value="filters.startDate"
|
||||
<label for="msd-station">偵測站</label>
|
||||
<select
|
||||
id="msd-station"
|
||||
:value="filters.station"
|
||||
:disabled="loading"
|
||||
@input="updateFilters({ startDate: $event.target.value })"
|
||||
/>
|
||||
class="filter-select"
|
||||
@change="updateFilters({ station: $event.target.value })"
|
||||
>
|
||||
<option
|
||||
v-for="opt in stationOptions"
|
||||
:key="opt.name"
|
||||
:value="opt.name"
|
||||
>
|
||||
{{ opt.label || opt.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-field">
|
||||
<label for="msd-end-date">結束</label>
|
||||
<input
|
||||
id="msd-end-date"
|
||||
type="date"
|
||||
:value="filters.endDate"
|
||||
<!-- Container mode: input type -->
|
||||
<div v-if="queryMode === 'container'" class="filter-field">
|
||||
<label for="msd-container-type">輸入類型</label>
|
||||
<select
|
||||
id="msd-container-type"
|
||||
class="filter-select"
|
||||
:value="containerInputType"
|
||||
:disabled="loading"
|
||||
@input="updateFilters({ endDate: $event.target.value })"
|
||||
/>
|
||||
@change="$emit('update:containerInputType', $event.target.value)"
|
||||
>
|
||||
<option value="lot">LOT</option>
|
||||
<option value="work_order">工單</option>
|
||||
<option value="wafer_lot">WAFER LOT</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Shared: direction -->
|
||||
<div class="filter-field">
|
||||
<label>方向</label>
|
||||
<div class="direction-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="direction-btn"
|
||||
:class="{ active: filters.direction === 'backward' }"
|
||||
:disabled="loading"
|
||||
@click="updateFilters({ direction: 'backward' })"
|
||||
>
|
||||
反向追溯
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="direction-btn"
|
||||
:class="{ active: filters.direction === 'forward' }"
|
||||
:disabled="loading"
|
||||
@click="updateFilters({ direction: 'forward' })"
|
||||
>
|
||||
正向追溯
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date range mode: dates -->
|
||||
<template v-if="queryMode === 'date_range'">
|
||||
<div class="filter-field">
|
||||
<label for="msd-start-date">開始</label>
|
||||
<input
|
||||
id="msd-start-date"
|
||||
type="date"
|
||||
:value="filters.startDate"
|
||||
:disabled="loading"
|
||||
@input="updateFilters({ startDate: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-field">
|
||||
<label for="msd-end-date">結束</label>
|
||||
<input
|
||||
id="msd-end-date"
|
||||
type="date"
|
||||
:value="filters.endDate"
|
||||
:disabled="loading"
|
||||
@input="updateFilters({ endDate: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Shared: loss reasons -->
|
||||
<div class="filter-field">
|
||||
<label>不良原因</label>
|
||||
<MultiSelect
|
||||
@@ -72,6 +181,33 @@ function updateFilters(patch) {
|
||||
查詢
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Container mode: textarea input -->
|
||||
<div v-if="queryMode === 'container'" class="container-input-row">
|
||||
<textarea
|
||||
class="filter-textarea"
|
||||
rows="3"
|
||||
:value="containerInput"
|
||||
:disabled="loading"
|
||||
placeholder="每行一個,支援 * 或 % wildcard GA26020001-A00-001 GA260200% ..."
|
||||
@input="$emit('update:containerInput', $event.target.value)"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Resolution info (container mode) -->
|
||||
<div
|
||||
v-if="resolutionInfo && queryMode === 'container'"
|
||||
class="resolution-info"
|
||||
>
|
||||
已解析 {{ resolutionInfo.resolved_count }} 筆容器
|
||||
<template v-if="resolutionInfo.not_found?.length > 0">
|
||||
<span class="resolution-warn">
|
||||
({{ resolutionInfo.not_found.length }} 筆未找到:
|
||||
{{ resolutionInfo.not_found.slice(0, 10).join(', ')
|
||||
}}{{ resolutionInfo.not_found.length > 10 ? '...' : '' }})
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -10,11 +10,19 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'backward',
|
||||
},
|
||||
stationLabel: {
|
||||
type: String,
|
||||
default: '測試',
|
||||
},
|
||||
});
|
||||
|
||||
const cards = computed(() => [
|
||||
const backwardCards = computed(() => [
|
||||
{
|
||||
label: 'TMTT 投入數',
|
||||
label: `${props.stationLabel} 投入數`,
|
||||
value: formatNumber(props.kpi.total_input),
|
||||
unit: 'pcs',
|
||||
color: '#3b82f6',
|
||||
@@ -52,6 +60,49 @@ const cards = computed(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
const forwardCards = computed(() => [
|
||||
{
|
||||
label: '偵測批次數',
|
||||
value: formatNumber(props.kpi.detection_lot_count),
|
||||
unit: 'lots',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
label: '偵測不良數',
|
||||
value: formatNumber(props.kpi.detection_defect_qty),
|
||||
unit: 'pcs',
|
||||
color: '#ef4444',
|
||||
},
|
||||
{
|
||||
label: '追蹤批次數',
|
||||
value: formatNumber(props.kpi.tracked_lot_count),
|
||||
unit: 'lots',
|
||||
color: '#6366f1',
|
||||
},
|
||||
{
|
||||
label: '下游到達站數',
|
||||
value: formatNumber(props.kpi.downstream_stations_reached),
|
||||
unit: '站',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
label: '下游不良總數',
|
||||
value: formatNumber(props.kpi.downstream_total_reject),
|
||||
unit: 'pcs',
|
||||
color: '#f59e0b',
|
||||
},
|
||||
{
|
||||
label: '下游不良率',
|
||||
value: formatRate(props.kpi.downstream_reject_rate),
|
||||
unit: '%',
|
||||
color: '#10b981',
|
||||
},
|
||||
]);
|
||||
|
||||
const cards = computed(() => (
|
||||
props.direction === 'forward' ? forwardCards.value : backwardCards.value
|
||||
));
|
||||
|
||||
function formatNumber(v) {
|
||||
if (v == null || v === 0) return '0';
|
||||
return Number(v).toLocaleString();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -18,12 +18,18 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const rootRef = ref(null);
|
||||
const searchRef = ref(null);
|
||||
const isOpen = ref(false);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const normalizedOptions = computed(() => {
|
||||
return props.options.map((option) => {
|
||||
@@ -43,6 +49,14 @@ const normalizedOptions = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase();
|
||||
if (!q) return normalizedOptions.value;
|
||||
return normalizedOptions.value.filter(
|
||||
(opt) => opt.label.toLowerCase().includes(q) || opt.value.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const selectedSet = computed(() => new Set((props.modelValue || []).map((value) => String(value))));
|
||||
|
||||
const selectedText = computed(() => {
|
||||
@@ -71,6 +85,17 @@ function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value;
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
searchQuery.value = '';
|
||||
nextTick(() => {
|
||||
if (props.searchable && searchRef.value) {
|
||||
searchRef.value.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function isSelected(value) {
|
||||
return selectedSet.value.has(String(value));
|
||||
}
|
||||
@@ -131,9 +156,19 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
|
||||
<div v-if="isOpen" class="multi-select-dropdown">
|
||||
<div v-if="searchable" class="multi-select-search">
|
||||
<input
|
||||
ref="searchRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="multi-select-search-input"
|
||||
placeholder="搜尋..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="multi-select-options">
|
||||
<button
|
||||
v-for="option in normalizedOptions"
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="multi-select-option"
|
||||
@@ -142,6 +177,9 @@ onBeforeUnmount(() => {
|
||||
<input type="checkbox" :checked="isSelected(option.value)" tabindex="-1" />
|
||||
<span>{{ option.label }}</span>
|
||||
</button>
|
||||
<div v-if="filteredOptions.length === 0" class="multi-select-no-match">
|
||||
無符合項目
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="multi-select-actions">
|
||||
|
||||
@@ -120,7 +120,10 @@ const chartOption = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="chart-card">
|
||||
<h3 class="chart-title">{{ title }}</h3>
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">{{ title }}</h3>
|
||||
<slot name="header-extra" />
|
||||
</div>
|
||||
<VChart
|
||||
v-if="chartOption"
|
||||
class="chart-canvas"
|
||||
|
||||
@@ -71,6 +71,83 @@ body {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ====== Mode Tabs ====== */
|
||||
.mode-tab-row {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--msd-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
background: #f8fafc;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.mode-tab.active {
|
||||
background: var(--msd-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mode-tab:not(:last-child) {
|
||||
border-right: 1px solid var(--msd-border);
|
||||
}
|
||||
|
||||
.mode-tab:hover:not(.active) {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ====== Container Input ====== */
|
||||
.container-input-row {
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
|
||||
.filter-textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-family: monospace;
|
||||
line-height: 1.5;
|
||||
border: 1px solid var(--msd-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.filter-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--msd-primary);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.filter-textarea:disabled {
|
||||
background: #f1f5f9;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ====== Resolution Info ====== */
|
||||
.resolution-info {
|
||||
padding: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.resolution-warn {
|
||||
color: #b45309;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ====== Filter Bar ====== */
|
||||
.filter-row {
|
||||
display: flex;
|
||||
@@ -139,6 +216,54 @@ body {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
/* ====== Filter Select ====== */
|
||||
.filter-select {
|
||||
border: 1px solid var(--msd-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
background: #ffffff;
|
||||
color: #1f2937;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* ====== Direction Toggle ====== */
|
||||
.direction-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--msd-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.direction-btn {
|
||||
border: none;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.direction-btn + .direction-btn {
|
||||
border-left: 1px solid var(--msd-border);
|
||||
}
|
||||
|
||||
.direction-btn.active {
|
||||
background: var(--msd-primary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.direction-btn:hover:not(:disabled):not(.active) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.direction-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ====== MultiSelect ====== */
|
||||
.multi-select {
|
||||
position: relative;
|
||||
@@ -220,6 +345,33 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.multi-select-search {
|
||||
padding: 8px 8px 4px;
|
||||
}
|
||||
|
||||
.multi-select-search-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--msd-border);
|
||||
border-radius: 5px;
|
||||
padding: 5px 8px;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
background: #ffffff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.multi-select-search-input:focus {
|
||||
border-color: var(--msd-primary);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.multi-select-no-match {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--msd-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.multi-select-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -322,6 +474,18 @@ body {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-header .chart-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
@@ -329,6 +493,48 @@ body {
|
||||
color: var(--msd-text);
|
||||
}
|
||||
|
||||
.chart-inline-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chart-inline-filters .multi-select {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.chart-inline-filters .multi-select-trigger {
|
||||
padding: 3px 7px;
|
||||
font-size: 12px;
|
||||
border-color: var(--msd-border);
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.chart-inline-filters .multi-select-dropdown {
|
||||
min-width: 220px;
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.chart-inline-filter {
|
||||
border: 1px solid var(--msd-border);
|
||||
border-radius: 5px;
|
||||
padding: 3px 7px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
max-width: 140px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chart-inline-filter:focus {
|
||||
outline: 2px solid var(--msd-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
@@ -456,79 +662,79 @@ body {
|
||||
}
|
||||
|
||||
/* ====== Empty State ====== */
|
||||
.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;
|
||||
}
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,14 +62,14 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
|
||||
() => import('../query-tool/App.vue'),
|
||||
[() => import('../query-tool/style.css')],
|
||||
),
|
||||
'/tmtt-defect': createNativeLoader(
|
||||
() => import('../tmtt-defect/App.vue'),
|
||||
[() => import('../tmtt-defect/style.css')],
|
||||
),
|
||||
'/tables': createNativeLoader(
|
||||
() => import('../tables/App.vue'),
|
||||
[() => import('../tables/style.css')],
|
||||
),
|
||||
'/mid-section-defect': createNativeLoader(
|
||||
() => import('../mid-section-defect/App.vue'),
|
||||
[() => import('../mid-section-defect/style.css')],
|
||||
),
|
||||
'/admin/performance': createNativeLoader(
|
||||
() => import('../admin-performance/App.vue'),
|
||||
[() => import('../admin-performance/style.css')],
|
||||
|
||||
@@ -9,7 +9,6 @@ const IN_SCOPE_REPORT_ROUTES = Object.freeze([
|
||||
'/resource-history',
|
||||
'/qc-gate',
|
||||
'/job-query',
|
||||
'/tmtt-defect',
|
||||
'/tables',
|
||||
'/excel-query',
|
||||
'/query-tool',
|
||||
@@ -165,17 +164,6 @@ const ROUTE_CONTRACTS = Object.freeze({
|
||||
scope: 'in-scope',
|
||||
compatibilityPolicy: 'redirect_to_shell_when_spa_enabled',
|
||||
}),
|
||||
'/tmtt-defect': buildContract({
|
||||
route: '/tmtt-defect',
|
||||
routeId: 'tmtt-defect',
|
||||
renderMode: 'native',
|
||||
owner: 'frontend-mes-reporting',
|
||||
title: 'TMTT Defect',
|
||||
rollbackStrategy: 'fallback_to_legacy_route',
|
||||
visibilityPolicy: 'released_or_admin',
|
||||
scope: 'in-scope',
|
||||
compatibilityPolicy: 'redirect_to_shell_when_spa_enabled',
|
||||
}),
|
||||
'/admin/pages': buildContract({
|
||||
route: '/admin/pages',
|
||||
routeId: 'admin-pages',
|
||||
@@ -236,7 +224,7 @@ const ROUTE_CONTRACTS = Object.freeze({
|
||||
routeId: 'mid-section-defect',
|
||||
renderMode: 'native',
|
||||
owner: 'frontend-mes-reporting',
|
||||
title: '中段製程不良追溯',
|
||||
title: '製程不良追溯分析',
|
||||
rollbackStrategy: 'fallback_to_legacy_route',
|
||||
visibilityPolicy: 'released_or_admin',
|
||||
scope: 'in-scope',
|
||||
|
||||
@@ -8,6 +8,7 @@ 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_forward: ['upstream_history', 'downstream_rejects'],
|
||||
});
|
||||
|
||||
function stageKey(stageName) {
|
||||
@@ -31,23 +32,42 @@ function normalizeSeedContainerIds(seedPayload) {
|
||||
return containerIds;
|
||||
}
|
||||
|
||||
function collectAllContainerIds(seedContainerIds, lineagePayload) {
|
||||
function collectAllContainerIds(seedContainerIds, lineagePayload, direction) {
|
||||
const seen = new Set(seedContainerIds);
|
||||
const merged = [...seedContainerIds];
|
||||
const ancestors = lineagePayload?.ancestors || {};
|
||||
Object.values(ancestors).forEach((values) => {
|
||||
if (!Array.isArray(values)) {
|
||||
return;
|
||||
|
||||
if (direction === 'forward') {
|
||||
const childrenMap = lineagePayload?.children_map || {};
|
||||
const queue = [...seedContainerIds];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
const children = childrenMap[current];
|
||||
if (!Array.isArray(children)) continue;
|
||||
for (const child of children) {
|
||||
const id = String(child || '').trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
merged.push(id);
|
||||
queue.push(id);
|
||||
}
|
||||
}
|
||||
values.forEach((value) => {
|
||||
const id = String(value || '').trim();
|
||||
if (!id || seen.has(id)) {
|
||||
} else {
|
||||
const ancestors = lineagePayload?.ancestors || {};
|
||||
Object.values(ancestors).forEach((values) => {
|
||||
if (!Array.isArray(values)) {
|
||||
return;
|
||||
}
|
||||
seen.add(id);
|
||||
merged.push(id);
|
||||
values.forEach((value) => {
|
||||
const id = String(value || '').trim();
|
||||
if (!id || seen.has(id)) {
|
||||
return;
|
||||
}
|
||||
seen.add(id);
|
||||
merged.push(id);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -89,7 +109,11 @@ export function useTraceProgress({ profile } = {}) {
|
||||
}
|
||||
|
||||
async function execute(params = {}) {
|
||||
const domains = PROFILE_DOMAINS[profile];
|
||||
const direction = params.direction || 'backward';
|
||||
const domainKey = profile === 'mid_section_defect' && direction === 'forward'
|
||||
? 'mid_section_defect_forward'
|
||||
: profile;
|
||||
const domains = PROFILE_DOMAINS[domainKey];
|
||||
if (!domains) {
|
||||
throw new Error(`Unsupported trace profile: ${profile}`);
|
||||
}
|
||||
@@ -123,13 +147,14 @@ export function useTraceProgress({ profile } = {}) {
|
||||
profile,
|
||||
container_ids: seedContainerIds,
|
||||
cache_key: seedPayload?.cache_key || null,
|
||||
params,
|
||||
},
|
||||
{ timeout: DEFAULT_STAGE_TIMEOUT_MS, signal: controller.signal },
|
||||
);
|
||||
stage_results.lineage = lineagePayload;
|
||||
completed_stages.value = [...completed_stages.value, 'lineage'];
|
||||
|
||||
const allContainerIds = collectAllContainerIds(seedContainerIds, lineagePayload);
|
||||
const allContainerIds = collectAllContainerIds(seedContainerIds, lineagePayload, direction);
|
||||
current_stage.value = 'events';
|
||||
const eventsPayload = await apiPost(
|
||||
'/api/trace/events',
|
||||
@@ -142,6 +167,7 @@ export function useTraceProgress({ profile } = {}) {
|
||||
seed_container_ids: seedContainerIds,
|
||||
lineage: {
|
||||
ancestors: lineagePayload?.ancestors || {},
|
||||
children_map: lineagePayload?.children_map || {},
|
||||
},
|
||||
},
|
||||
{ timeout: DEFAULT_STAGE_TIMEOUT_MS, signal: controller.signal },
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import FilterToolbar from '../shared-ui/components/FilterToolbar.vue';
|
||||
import SectionCard from '../shared-ui/components/SectionCard.vue';
|
||||
import StatusBadge from '../shared-ui/components/StatusBadge.vue';
|
||||
import TmttChartCard from './components/TmttChartCard.vue';
|
||||
import TmttDetailTable from './components/TmttDetailTable.vue';
|
||||
import TmttKpiCards from './components/TmttKpiCards.vue';
|
||||
import { useTmttDefectData } from './composables/useTmttDefectData.js';
|
||||
|
||||
const {
|
||||
startDate,
|
||||
endDate,
|
||||
loading,
|
||||
errorMessage,
|
||||
hasData,
|
||||
kpi,
|
||||
charts,
|
||||
dailyTrend,
|
||||
filteredRows,
|
||||
totalCount,
|
||||
filteredCount,
|
||||
activeFilter,
|
||||
sortState,
|
||||
queryData,
|
||||
setFilter,
|
||||
clearFilter,
|
||||
toggleSort,
|
||||
exportCsv,
|
||||
} = useTmttDefectData();
|
||||
|
||||
const paretoCharts = [
|
||||
{ key: 'by_workflow', field: 'WORKFLOW', title: '依 WORKFLOW' },
|
||||
{ key: 'by_package', field: 'PRODUCTLINENAME', title: '依 PACKAGE' },
|
||||
{ key: 'by_type', field: 'PJ_TYPE', title: '依 TYPE' },
|
||||
{ key: 'by_tmtt_machine', field: 'TMTT_EQUIPMENTNAME', title: '依 TMTT 機台' },
|
||||
{ key: 'by_mold_machine', field: 'MOLD_EQUIPMENTNAME', title: '依 MOLD 機台' },
|
||||
];
|
||||
|
||||
const detailCountLabel = computed(() => {
|
||||
if (!activeFilter.value) {
|
||||
return `${filteredCount.value} 筆`;
|
||||
}
|
||||
return `${filteredCount.value} / ${totalCount.value} 筆`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tmtt-page u-content-shell">
|
||||
<header class="tmtt-header">
|
||||
<h1>TMTT 印字與腳型不良分析</h1>
|
||||
<p>Legacy rewrite exemplar:Vue 元件化 + Shared UI + Tailwind token layer</p>
|
||||
</header>
|
||||
|
||||
<div class="u-panel-stack">
|
||||
<SectionCard>
|
||||
<template #header>
|
||||
<div class="tmtt-block-title">查詢條件</div>
|
||||
</template>
|
||||
|
||||
<FilterToolbar>
|
||||
<label class="tmtt-field">
|
||||
<span>起始日期</span>
|
||||
<input v-model="startDate" type="date" />
|
||||
</label>
|
||||
<label class="tmtt-field">
|
||||
<span>結束日期</span>
|
||||
<input v-model="endDate" type="date" />
|
||||
</label>
|
||||
|
||||
<template #actions>
|
||||
<button type="button" class="tmtt-btn tmtt-btn-primary" :disabled="loading" @click="queryData">
|
||||
{{ loading ? '查詢中...' : '查詢' }}
|
||||
</button>
|
||||
<button type="button" class="tmtt-btn tmtt-btn-success" :disabled="loading" @click="exportCsv">
|
||||
匯出 CSV
|
||||
</button>
|
||||
</template>
|
||||
</FilterToolbar>
|
||||
</SectionCard>
|
||||
|
||||
<p v-if="errorMessage" class="tmtt-error-banner">{{ errorMessage }}</p>
|
||||
|
||||
<template v-if="hasData">
|
||||
<TmttKpiCards :kpi="kpi" />
|
||||
|
||||
<div class="tmtt-chart-grid">
|
||||
<TmttChartCard
|
||||
v-for="config in paretoCharts"
|
||||
:key="config.key"
|
||||
:title="config.title"
|
||||
mode="pareto"
|
||||
:field="config.field"
|
||||
:selected-value="activeFilter?.value || ''"
|
||||
:data="charts[config.key] || []"
|
||||
@select="setFilter"
|
||||
/>
|
||||
|
||||
<TmttChartCard
|
||||
title="每日印字不良率趨勢"
|
||||
mode="print-trend"
|
||||
:data="dailyTrend"
|
||||
line-label="印字不良率"
|
||||
line-color="#ef4444"
|
||||
/>
|
||||
|
||||
<TmttChartCard
|
||||
title="每日腳型不良率趨勢"
|
||||
mode="lead-trend"
|
||||
:data="dailyTrend"
|
||||
line-label="腳型不良率"
|
||||
line-color="#f59e0b"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionCard>
|
||||
<template #header>
|
||||
<div class="tmtt-detail-header">
|
||||
<div>
|
||||
<strong>明細清單</strong>
|
||||
<span class="tmtt-detail-count">({{ detailCountLabel }})</span>
|
||||
</div>
|
||||
<div class="tmtt-detail-actions">
|
||||
<StatusBadge
|
||||
v-if="activeFilter"
|
||||
tone="warning"
|
||||
:text="activeFilter.label"
|
||||
/>
|
||||
<button
|
||||
v-if="activeFilter"
|
||||
type="button"
|
||||
class="tmtt-btn tmtt-btn-ghost"
|
||||
@click="clearFilter"
|
||||
>
|
||||
清除篩選
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<TmttDetailTable :rows="filteredRows" :sort-state="sortState" @sort="toggleSort" />
|
||||
</SectionCard>
|
||||
</template>
|
||||
|
||||
<SectionCard v-else>
|
||||
<div class="tmtt-empty-state">
|
||||
<p>請選擇日期範圍後點擊「查詢」。</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,177 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import VChart from 'vue-echarts';
|
||||
import { use } from 'echarts/core';
|
||||
import { BarChart, LineChart } from 'echarts/charts';
|
||||
import { GridComponent, LegendComponent, TooltipComponent, TitleComponent } from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
use([CanvasRenderer, BarChart, LineChart, GridComponent, LegendComponent, TooltipComponent, TitleComponent]);
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'pareto',
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
field: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
lineLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
lineColor: {
|
||||
type: String,
|
||||
default: '#6366f1',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
function emptyOption() {
|
||||
return {
|
||||
title: {
|
||||
text: '無資料',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
textStyle: { color: '#94a3b8', fontSize: 14 },
|
||||
},
|
||||
xAxis: { show: false },
|
||||
yAxis: { show: false },
|
||||
series: [],
|
||||
};
|
||||
}
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const data = props.data || [];
|
||||
if (!data.length) {
|
||||
return emptyOption();
|
||||
}
|
||||
|
||||
if (props.mode === 'pareto') {
|
||||
const names = data.map((item) => item.name);
|
||||
const printRates = data.map((item) => Number(item.print_defect_rate || 0));
|
||||
const leadRates = data.map((item) => Number(item.lead_defect_rate || 0));
|
||||
const cumPct = data.map((item) => Number(item.cumulative_pct || 0));
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
},
|
||||
legend: { data: ['印字不良率', '腳型不良率', '累積%'], bottom: 0 },
|
||||
grid: { left: 56, right: 56, top: 24, bottom: names.length > 8 ? 90 : 56 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: names,
|
||||
axisLabel: {
|
||||
rotate: names.length > 8 ? 35 : 0,
|
||||
interval: 0,
|
||||
formatter: (value) => (value.length > 16 ? `${value.slice(0, 16)}...` : value),
|
||||
},
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '不良率(%)', splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
{ type: 'value', name: '累積%', max: 100 },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '印字不良率',
|
||||
type: 'bar',
|
||||
stack: 'defect',
|
||||
data: printRates,
|
||||
itemStyle: { color: '#ef4444' },
|
||||
barMaxWidth: 40,
|
||||
},
|
||||
{
|
||||
name: '腳型不良率',
|
||||
type: 'bar',
|
||||
stack: 'defect',
|
||||
data: leadRates,
|
||||
itemStyle: { color: '#f59e0b' },
|
||||
barMaxWidth: 40,
|
||||
},
|
||||
{
|
||||
name: '累積%',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: cumPct,
|
||||
itemStyle: { color: '#6366f1' },
|
||||
lineStyle: { width: 2 },
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const dates = data.map((item) => item.date);
|
||||
const lineValues = data.map((item) => Number(item[props.mode === 'print-trend' ? 'print_defect_rate' : 'lead_defect_rate'] || 0));
|
||||
const inputValues = data.map((item) => Number(item.input_qty || 0));
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: [props.lineLabel || '趨勢', '投入數'], bottom: 0 },
|
||||
grid: { left: 56, right: 56, top: 24, bottom: 56 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: { rotate: dates.length > 14 ? 35 : 0 },
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '不良率(%)', splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
{ type: 'value', name: '投入數' },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: props.lineLabel || '趨勢',
|
||||
type: 'line',
|
||||
data: lineValues,
|
||||
itemStyle: { color: props.lineColor },
|
||||
lineStyle: { width: 2 },
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
},
|
||||
{
|
||||
name: '投入數',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
data: inputValues,
|
||||
itemStyle: { color: '#c7d2fe' },
|
||||
barMaxWidth: 20,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
function handleClick(params) {
|
||||
if (props.mode !== 'pareto' || params?.componentType !== 'series' || !params?.name || !props.field) {
|
||||
return;
|
||||
}
|
||||
emit('select', {
|
||||
field: props.field,
|
||||
value: params.name,
|
||||
label: `${props.field}: ${params.name}`,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="tmtt-chart-card">
|
||||
<h3>{{ title }}</h3>
|
||||
<VChart class="tmtt-chart-canvas" :option="chartOption" autoresize @click="handleClick" />
|
||||
</article>
|
||||
</template>
|
||||
@@ -1,87 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
sortState: {
|
||||
type: Object,
|
||||
default: () => ({ column: '', asc: true }),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sort']);
|
||||
|
||||
const columns = computed(() => [
|
||||
{ key: 'CONTAINERNAME', label: 'LOT ID' },
|
||||
{ key: 'PJ_TYPE', label: 'TYPE' },
|
||||
{ key: 'PRODUCTLINENAME', label: 'PACKAGE' },
|
||||
{ key: 'WORKFLOW', label: 'WORKFLOW' },
|
||||
{ key: 'FINISHEDRUNCARD', label: '完工流水碼' },
|
||||
{ key: 'TMTT_EQUIPMENTNAME', label: 'TMTT設備' },
|
||||
{ key: 'MOLD_EQUIPMENTNAME', label: 'MOLD設備' },
|
||||
{ key: 'INPUT_QTY', label: '投入數', numeric: true },
|
||||
{ key: 'PRINT_DEFECT_QTY', label: '印字不良', numeric: true, danger: true },
|
||||
{ key: 'PRINT_DEFECT_RATE', label: '印字不良率(%)', numeric: true, danger: true, decimal: 4 },
|
||||
{ key: 'LEAD_DEFECT_QTY', label: '腳型不良', numeric: true, warning: true },
|
||||
{ key: 'LEAD_DEFECT_RATE', label: '腳型不良率(%)', numeric: true, warning: true, decimal: 4 },
|
||||
]);
|
||||
|
||||
function formatNumber(value, decimal = null) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
if (Number.isInteger(decimal) && decimal >= 0) {
|
||||
return n.toFixed(decimal);
|
||||
}
|
||||
return n.toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function sortIndicator(key) {
|
||||
if (props.sortState?.column !== key) {
|
||||
return '';
|
||||
}
|
||||
return props.sortState?.asc ? '▲' : '▼';
|
||||
}
|
||||
|
||||
function cellClass(column) {
|
||||
const classes = [];
|
||||
if (column.numeric) classes.push('is-numeric');
|
||||
if (column.danger) classes.push('is-danger');
|
||||
if (column.warning) classes.push('is-warning');
|
||||
return classes;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tmtt-detail-table-wrap">
|
||||
<table class="tmtt-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in columns" :key="column.key">
|
||||
<button type="button" class="tmtt-sort-btn" @click="emit('sort', column.key)">
|
||||
{{ column.label }}
|
||||
<span class="tmtt-sort-indicator">{{ sortIndicator(column.key) }}</span>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="rows.length === 0">
|
||||
<td :colspan="columns.length" class="tmtt-empty-row">無資料</td>
|
||||
</tr>
|
||||
<tr v-for="(row, index) in rows" v-else :key="`${row.CONTAINERNAME || 'row'}-${index}`">
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="`${row.CONTAINERNAME || 'row'}-${column.key}-${index}`"
|
||||
:class="cellClass(column)"
|
||||
>
|
||||
<template v-if="column.numeric">{{ formatNumber(row[column.key], column.decimal) }}</template>
|
||||
<template v-else>{{ row[column.key] || '' }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,51 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
kpi: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
function fmtNumber(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '-';
|
||||
return n.toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function fmtRate(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '-';
|
||||
return n.toFixed(4);
|
||||
}
|
||||
|
||||
const cards = computed(() => {
|
||||
const value = props.kpi || {};
|
||||
return [
|
||||
{ key: 'total_input', label: '投入數', display: fmtNumber(value.total_input), tone: 'neutral' },
|
||||
{ key: 'lot_count', label: 'LOT 數', display: fmtNumber(value.lot_count), tone: 'neutral' },
|
||||
{ key: 'print_defect_qty', label: '印字不良數', display: fmtNumber(value.print_defect_qty), tone: 'danger' },
|
||||
{ key: 'print_defect_rate', label: '印字不良率', display: fmtRate(value.print_defect_rate), unit: '%', tone: 'danger' },
|
||||
{ key: 'lead_defect_qty', label: '腳型不良數', display: fmtNumber(value.lead_defect_qty), tone: 'warning' },
|
||||
{ key: 'lead_defect_rate', label: '腳型不良率', display: fmtRate(value.lead_defect_rate), unit: '%', tone: 'warning' },
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tmtt-kpi-grid">
|
||||
<article
|
||||
v-for="card in cards"
|
||||
:key="card.key"
|
||||
class="tmtt-kpi-card"
|
||||
:class="`tone-${card.tone}`"
|
||||
>
|
||||
<p class="tmtt-kpi-label">{{ card.label }}</p>
|
||||
<p class="tmtt-kpi-value">
|
||||
{{ card.display }}
|
||||
<span v-if="card.unit" class="tmtt-kpi-unit">{{ card.unit }}</span>
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,216 +0,0 @@
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { apiGet, ensureMesApiAvailable } from '../../core/api.js';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const NUMERIC_COLUMNS = new Set([
|
||||
'INPUT_QTY',
|
||||
'PRINT_DEFECT_QTY',
|
||||
'PRINT_DEFECT_RATE',
|
||||
'LEAD_DEFECT_QTY',
|
||||
'LEAD_DEFECT_RATE',
|
||||
]);
|
||||
|
||||
function notify(level, message) {
|
||||
const toast = globalThis.Toast;
|
||||
if (toast && typeof toast[level] === 'function') {
|
||||
return toast[level](message);
|
||||
}
|
||||
if (level === 'error') {
|
||||
console.error(message);
|
||||
} else {
|
||||
console.info(message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dismissToast(id) {
|
||||
if (!id) return;
|
||||
const toast = globalThis.Toast;
|
||||
if (toast && typeof toast.dismiss === 'function') {
|
||||
toast.dismiss(id);
|
||||
}
|
||||
}
|
||||
|
||||
function toComparable(value, key) {
|
||||
if (NUMERIC_COLUMNS.has(key)) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
if (value == null) return '';
|
||||
return String(value).toUpperCase();
|
||||
}
|
||||
|
||||
function toDateString(date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function useTmttDefectData() {
|
||||
const startDate = ref('');
|
||||
const endDate = ref('');
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const analysisData = ref(null);
|
||||
const activeFilter = ref(null);
|
||||
const sortState = ref({ column: '', asc: true });
|
||||
|
||||
function initializeDateRange() {
|
||||
if (startDate.value && endDate.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - 6);
|
||||
|
||||
startDate.value = toDateString(start);
|
||||
endDate.value = toDateString(end);
|
||||
}
|
||||
|
||||
const hasData = computed(() => Boolean(analysisData.value));
|
||||
const kpi = computed(() => analysisData.value?.kpi || null);
|
||||
const charts = computed(() => analysisData.value?.charts || {});
|
||||
const dailyTrend = computed(() => analysisData.value?.daily_trend || []);
|
||||
const rawDetailRows = computed(() => analysisData.value?.detail || []);
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
let rows = rawDetailRows.value;
|
||||
|
||||
if (activeFilter.value?.field && activeFilter.value?.value) {
|
||||
rows = rows.filter((row) => String(row?.[activeFilter.value.field] || '') === activeFilter.value.value);
|
||||
}
|
||||
|
||||
if (!sortState.value.column) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const sorted = [...rows].sort((left, right) => {
|
||||
const leftValue = toComparable(left?.[sortState.value.column], sortState.value.column);
|
||||
const rightValue = toComparable(right?.[sortState.value.column], sortState.value.column);
|
||||
if (leftValue < rightValue) {
|
||||
return sortState.value.asc ? -1 : 1;
|
||||
}
|
||||
if (leftValue > rightValue) {
|
||||
return sortState.value.asc ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const totalCount = computed(() => rawDetailRows.value.length);
|
||||
const filteredCount = computed(() => filteredRows.value.length);
|
||||
|
||||
async function queryData() {
|
||||
if (!startDate.value || !endDate.value) {
|
||||
notify('warning', '請選擇起始和結束日期');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
const loadingToastId = notify('loading', '查詢中...');
|
||||
|
||||
try {
|
||||
const result = await apiGet('/api/tmtt-defect/analysis', {
|
||||
params: {
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
},
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
if (!result || !result.success) {
|
||||
const message = result?.error || '查詢失敗';
|
||||
errorMessage.value = message;
|
||||
notify('error', message);
|
||||
return;
|
||||
}
|
||||
|
||||
analysisData.value = result.data;
|
||||
activeFilter.value = null;
|
||||
sortState.value = { column: '', asc: true };
|
||||
notify('success', '查詢完成');
|
||||
} catch (error) {
|
||||
const message = error?.message || '查詢失敗';
|
||||
errorMessage.value = message;
|
||||
notify('error', `查詢失敗: ${message}`);
|
||||
} finally {
|
||||
dismissToast(loadingToastId);
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setFilter({ field, value, label }) {
|
||||
if (!field || !value) {
|
||||
return;
|
||||
}
|
||||
activeFilter.value = {
|
||||
field,
|
||||
value,
|
||||
label: label || `${field}: ${value}`,
|
||||
};
|
||||
}
|
||||
|
||||
function clearFilter() {
|
||||
activeFilter.value = null;
|
||||
}
|
||||
|
||||
function toggleSort(column) {
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sortState.value.column === column) {
|
||||
sortState.value = {
|
||||
column,
|
||||
asc: !sortState.value.asc,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
sortState.value = {
|
||||
column,
|
||||
asc: true,
|
||||
};
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
if (!startDate.value || !endDate.value) {
|
||||
notify('warning', '請先查詢資料');
|
||||
return;
|
||||
}
|
||||
|
||||
const query = new URLSearchParams({
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
});
|
||||
window.open(`/api/tmtt-defect/export?${query.toString()}`, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
initializeDateRange();
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
loading,
|
||||
errorMessage,
|
||||
hasData,
|
||||
kpi,
|
||||
charts,
|
||||
dailyTrend,
|
||||
rawDetailRows,
|
||||
filteredRows,
|
||||
totalCount,
|
||||
filteredCount,
|
||||
activeFilter,
|
||||
sortState,
|
||||
queryData,
|
||||
setFilter,
|
||||
clearFilter,
|
||||
toggleSort,
|
||||
exportCsv,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import '../styles/tailwind.css';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
@@ -1,285 +0,0 @@
|
||||
.tmtt-page {
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.tmtt-header {
|
||||
border-radius: 12px;
|
||||
padding: 22px 24px;
|
||||
margin-bottom: 16px;
|
||||
color: #fff;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--portal-brand-start, #667eea) 0%,
|
||||
var(--portal-brand-end, #764ba2) 100%
|
||||
);
|
||||
box-shadow: var(--portal-shadow-panel, 0 8px 24px rgba(79, 70, 229, 0.18));
|
||||
}
|
||||
|
||||
.tmtt-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.tmtt-header p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.tmtt-block-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tmtt-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.tmtt-field input {
|
||||
height: 34px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tmtt-field input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.tmtt-btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tmtt-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tmtt-btn-primary {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tmtt-btn-primary:hover:not(:disabled) {
|
||||
background: #4338ca;
|
||||
}
|
||||
|
||||
.tmtt-btn-success {
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tmtt-btn-success:hover:not(:disabled) {
|
||||
background: #15803d;
|
||||
}
|
||||
|
||||
.tmtt-btn-ghost {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.tmtt-btn-ghost:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.tmtt-error-banner {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tmtt-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tmtt-kpi-card {
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
border-left: 4px solid #64748b;
|
||||
background: #fff;
|
||||
box-shadow: var(--portal-shadow-soft, 0 2px 8px rgba(15, 23, 42, 0.08));
|
||||
}
|
||||
|
||||
.tmtt-kpi-card.tone-danger {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.tmtt-kpi-card.tone-warning {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.tmtt-kpi-label {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tmtt-kpi-value {
|
||||
margin: 8px 0 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.tmtt-kpi-unit {
|
||||
font-size: 12px;
|
||||
margin-left: 4px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tmtt-chart-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tmtt-chart-card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.tmtt-chart-card h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tmtt-chart-canvas {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tmtt-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tmtt-detail-count {
|
||||
margin-left: 6px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tmtt-detail-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tmtt-detail-table-wrap {
|
||||
overflow: auto;
|
||||
max-height: 520px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tmtt-detail-table {
|
||||
width: 100%;
|
||||
min-width: 1500px;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tmtt-detail-table th,
|
||||
.tmtt-detail-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tmtt-detail-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.tmtt-sort-btn {
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tmtt-sort-indicator {
|
||||
color: #64748b;
|
||||
min-width: 10px;
|
||||
}
|
||||
|
||||
.tmtt-detail-table tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.tmtt-detail-table .is-numeric {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tmtt-detail-table .is-danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.tmtt-detail-table .is-warning {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.tmtt-empty-row {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.tmtt-empty-state {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.tmtt-kpi-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.tmtt-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tmtt-kpi-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.tmtt-detail-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,6 @@ test('table parity: list/detail pages preserve pagination and sort continuity ho
|
||||
assert.match(holdDetailSource, /page|currentPage|perPage/);
|
||||
assert.match(holdDetailSource, /distribution|lots/i);
|
||||
|
||||
const tmttTableSource = readSource('src/tmtt-defect/components/TmttDetailTable.vue');
|
||||
assert.match(tmttTableSource, /sort/i);
|
||||
});
|
||||
|
||||
test('chart parity: chart pages keep tooltip, legend, autoresize and click linkage', () => {
|
||||
@@ -53,10 +51,6 @@ test('chart parity: chart pages keep tooltip, legend, autoresize and click linka
|
||||
assert.match(holdParetoSource, /legend\s*:/);
|
||||
assert.match(holdParetoSource, /@click="handleChartClick"/);
|
||||
|
||||
const tmttChartSource = readSource('src/tmtt-defect/components/TmttChartCard.vue');
|
||||
assert.match(tmttChartSource, /tooltip\s*:/);
|
||||
assert.match(tmttChartSource, /legend\s*:/);
|
||||
assert.match(tmttChartSource, /autoresize/);
|
||||
});
|
||||
|
||||
test('matrix interaction parity: selection/highlight/drill handlers remain present', () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ test('buildLaunchHref replaces existing query keys with latest runtime values',
|
||||
|
||||
test('buildLaunchHref ignores empty and null-like query values', () => {
|
||||
assert.equal(
|
||||
buildLaunchHref('/tmtt-defect', { start_date: '', end_date: null, shift: undefined }),
|
||||
'/tmtt-defect',
|
||||
buildLaunchHref('/mid-section-defect', { start_date: '', end_date: null, shift: undefined }),
|
||||
'/mid-section-defect',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ const WAVE_A_ROUTES = Object.freeze([
|
||||
'/resource',
|
||||
'/resource-history',
|
||||
'/qc-gate',
|
||||
'/tmtt-defect',
|
||||
]);
|
||||
|
||||
const WAVE_B_NATIVE_ROUTES = Object.freeze([
|
||||
|
||||
@@ -26,7 +26,6 @@ export default defineConfig(({ mode }) => ({
|
||||
'excel-query': resolve(__dirname, 'src/excel-query/main.js'),
|
||||
tables: resolve(__dirname, 'src/tables/index.html'),
|
||||
'query-tool': resolve(__dirname, 'src/query-tool/main.js'),
|
||||
'tmtt-defect': resolve(__dirname, 'src/tmtt-defect/main.js'),
|
||||
'qc-gate': resolve(__dirname, 'src/qc-gate/index.html'),
|
||||
'mid-section-defect': resolve(__dirname, 'src/mid-section-defect/index.html'),
|
||||
'admin-performance': resolve(__dirname, 'src/admin-performance/index.html')
|
||||
|
||||
Reference in New Issue
Block a user