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:
egg
2026-02-24 16:16:33 +08:00
parent bb58a0e119
commit f14591c7dc
67 changed files with 2957 additions and 2931 deletions

View File

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

View File

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

View File

@@ -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&#10;GA26020001-A00-001&#10;GA260200%&#10;..."
@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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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')],

View File

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

View File

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

View File

@@ -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 exemplarVue 元件化 + 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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
import { createApp } from 'vue';
import '../styles/tailwind.css';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');

View File

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

View File

@@ -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', () => {

View File

@@ -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',
);
});

View File

@@ -15,7 +15,6 @@ const WAVE_A_ROUTES = Object.freeze([
'/resource',
'/resource-history',
'/qc-gate',
'/tmtt-defect',
]);
const WAVE_B_NATIVE_ROUTES = Object.freeze([

View File

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