feat(reject-history): two-phase query architecture with cached views

Replace per-interaction Oracle queries with a two-phase model:
- POST /query: single Oracle hit, cache full LOT-level DataFrame (L1+L2)
- GET /view: read cache, apply supplementary/interactive filters via pandas

Add container query mode (LOT/工單/WAFER LOT with wildcard support),
supplementary filters (Package/WC GROUP/Reason) from cached data,
PB_* series exclusion (was PB_Diode only), and query loading spinner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-23 07:08:27 +08:00
parent 57a0b780b1
commit 58e4c87fb6
11 changed files with 1707 additions and 657 deletions

View File

@@ -149,3 +149,53 @@ export function buildRejectCommonQueryParams(filters = {}, { reason = '' } = {})
}
return params;
}
export function parseMultiLineInput(text) {
if (!text) return [];
const tokens = String(text)
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => s.replace(/\*/g, '%'));
const seen = new Set();
const result = [];
for (const token of tokens) {
if (!seen.has(token)) {
seen.add(token);
result.push(token);
}
}
return result;
}
export function buildViewParams(queryId, {
supplementaryFilters = {},
metricFilter = 'all',
trendDates = [],
detailReason = '',
page = 1,
perPage = 50,
} = {}) {
const params = { query_id: queryId };
if (supplementaryFilters.packages?.length > 0) {
params.packages = supplementaryFilters.packages;
}
if (supplementaryFilters.workcenterGroups?.length > 0) {
params.workcenter_groups = supplementaryFilters.workcenterGroups;
}
if (supplementaryFilters.reason) {
params.reason = supplementaryFilters.reason;
}
if (metricFilter && metricFilter !== 'all') {
params.metric_filter = metricFilter;
}
if (trendDates?.length > 0) {
params.trend_dates = trendDates;
}
if (detailReason) {
params.detail_reason = detailReason;
}
params.page = page || 1;
params.per_page = perPage || 50;
return params;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,39 @@
<script setup>
import MultiSelect from '../../resource-shared/components/MultiSelect.vue';
defineProps({
const props = defineProps({
filters: { type: Object, required: true },
options: { type: Object, required: true },
queryMode: { type: String, default: 'date_range' },
containerInputType: { type: String, default: 'lot' },
containerInput: { type: String, default: '' },
availableFilters: { type: Object, default: () => ({}) },
supplementaryFilters: { type: Object, default: () => ({}) },
queryId: { type: String, default: '' },
resolutionInfo: { type: Object, default: null },
loading: { type: Object, required: true },
activeFilterChips: { type: Array, default: () => [] },
});
defineEmits(['apply', 'clear', 'export-csv', 'remove-chip', 'pareto-scope-toggle']);
const emit = defineEmits([
'apply',
'clear',
'export-csv',
'remove-chip',
'pareto-scope-toggle',
'update:queryMode',
'update:containerInputType',
'update:containerInput',
'supplementary-change',
]);
function emitSupplementary(patch) {
emit('supplementary-change', {
packages: props.supplementaryFilters.packages || [],
workcenterGroups: props.supplementaryFilters.workcenterGroups || [],
reason: props.supplementaryFilters.reason || '',
...patch,
});
}
</script>
<template>
@@ -17,46 +42,75 @@ defineEmits(['apply', 'clear', 'export-csv', 'remove-chip', 'pareto-scope-toggle
<div class="card-title">查詢條件</div>
</div>
<div class="card-body filter-panel">
<div class="filter-group">
<label class="filter-label" for="start-date">開始日期</label>
<input id="start-date" v-model="filters.startDate" type="date" class="filter-input" />
</div>
<div class="filter-group">
<label class="filter-label" for="end-date">結束日期</label>
<input id="end-date" v-model="filters.endDate" type="date" class="filter-input" />
<!-- Mode toggle tabs -->
<div class="filter-group-full 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-group">
<label class="filter-label">Package</label>
<MultiSelect
:model-value="filters.packages"
:options="options.packages"
placeholder="全部 Package"
searchable
@update:model-value="filters.packages = $event"
/>
</div>
<!-- Date range mode -->
<template v-if="queryMode === 'date_range'">
<div class="filter-group">
<label class="filter-label" for="start-date">開始日期</label>
<input
id="start-date"
v-model="filters.startDate"
type="date"
class="filter-input"
/>
</div>
<div class="filter-group">
<label class="filter-label" for="end-date">結束日期</label>
<input
id="end-date"
v-model="filters.endDate"
type="date"
class="filter-input"
/>
</div>
</template>
<div class="filter-group">
<label class="filter-label" for="reason">報廢原因</label>
<select id="reason" v-model="filters.reason" class="filter-input">
<option value="">全部原因</option>
<option v-for="reason in options.reasons" :key="reason" :value="reason">
{{ reason }}
</option>
</select>
</div>
<div class="filter-group filter-group-full">
<label class="filter-label">WORKCENTER GROUP</label>
<MultiSelect
:model-value="filters.workcenterGroups"
:options="options.workcenterGroups"
placeholder="全部工作中心群組"
searchable
@update:model-value="filters.workcenterGroups = $event"
/>
</div>
<!-- Container mode -->
<template v-else>
<div class="filter-group">
<label class="filter-label" for="container-type">輸入類型</label>
<select
id="container-type"
class="filter-input"
:value="containerInputType"
@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>
<div class="filter-group filter-group-wide">
<label class="filter-label" for="container-input"
>輸入值 (每行一個支援 * % wildcard)</label
>
<textarea
id="container-input"
class="filter-input filter-textarea"
rows="3"
:value="containerInput"
@input="$emit('update:containerInput', $event.target.value)"
placeholder="GA26020001-A00-001&#10;GA260200%&#10;..."
></textarea>
</div>
</template>
<div class="filter-toolbar">
<div class="checkbox-row">
@@ -70,7 +124,7 @@ defineEmits(['apply', 'clear', 'export-csv', 'remove-chip', 'pareto-scope-toggle
</label>
<label class="checkbox-pill">
<input v-model="filters.excludePbDiode" type="checkbox" />
排除 PB_Diode
排除 PB_* 系列
</label>
<label class="checkbox-pill">
<input
@@ -82,19 +136,110 @@ defineEmits(['apply', 'clear', 'export-csv', 'remove-chip', 'pareto-scope-toggle
</label>
</div>
<div class="filter-actions">
<button class="btn btn-primary" :disabled="loading.querying" @click="$emit('apply')">查詢</button>
<button class="btn btn-secondary" :disabled="loading.querying" @click="$emit('clear')">清除條件</button>
<button class="btn btn-light btn-export" :disabled="loading.querying || loading.exporting" @click="$emit('export-csv')">
<template v-if="loading.exporting"><span class="btn-spinner"></span>匯出中...</template>
<button
class="btn btn-primary"
:disabled="loading.querying"
@click="$emit('apply')"
>
<template v-if="loading.querying"
><span class="btn-spinner"></span>查詢中...</template
>
<template v-else>查詢</template>
</button>
<button
class="btn btn-secondary"
:disabled="loading.querying"
@click="$emit('clear')"
>
清除條件
</button>
<button
class="btn btn-light btn-export"
:disabled="loading.querying || loading.exporting"
@click="$emit('export-csv')"
>
<template v-if="loading.exporting"
><span class="btn-spinner"></span>匯出中...</template
>
<template v-else>匯出 CSV</template>
</button>
</div>
</div>
</div>
<div class="card-body active-filter-chip-row" v-if="activeFilterChips.length > 0">
<!-- Resolution info (container mode) -->
<div
v-if="resolutionInfo && queryMode === 'container'"
class="card-body 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>
<!-- Supplementary filters (only after primary query) -->
<div v-if="queryId" class="supplementary-panel">
<div class="supplementary-header">補充篩選 (快取內篩選)</div>
<div class="supplementary-row">
<div class="filter-group">
<label class="filter-label">WORKCENTER GROUP</label>
<MultiSelect
:model-value="supplementaryFilters.workcenterGroups"
:options="availableFilters.workcenterGroups || []"
placeholder="全部工作中心群組"
searchable
@update:model-value="emitSupplementary({ workcenterGroups: $event })"
/>
</div>
<div class="filter-group">
<label class="filter-label">Package</label>
<MultiSelect
:model-value="supplementaryFilters.packages"
:options="availableFilters.packages || []"
placeholder="全部 Package"
searchable
@update:model-value="emitSupplementary({ packages: $event })"
/>
</div>
<div class="filter-group">
<label class="filter-label" for="supp-reason">報廢原因</label>
<select
id="supp-reason"
class="filter-input"
:value="supplementaryFilters.reason"
@change="emitSupplementary({ reason: $event.target.value })"
>
<option value="">全部原因</option>
<option
v-for="r in availableFilters.reasons || []"
:key="r"
:value="r"
>
{{ r }}
</option>
</select>
</div>
</div>
</div>
<div
class="card-body active-filter-chip-row"
v-if="activeFilterChips.length > 0"
>
<div class="filter-label">套用中篩選</div>
<div class="chip-list">
<div v-for="chip in activeFilterChips" :key="chip.key" class="filter-chip">
<div
v-for="chip in activeFilterChips"
:key="chip.key"
class="filter-chip"
>
<span>{{ chip.label }}</span>
<button
v-if="chip.removable"
@@ -102,7 +247,7 @@ defineEmits(['apply', 'clear', 'export-csv', 'remove-chip', 'pareto-scope-toggle
class="chip-remove"
@click="$emit('remove-chip', chip)"
>
×
&times;
</button>
</div>
</div>

View File

@@ -2,11 +2,81 @@
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.mode-tab-row {
display: flex;
gap: 0;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
width: fit-content;
}
.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:not(:last-child) {
border-right: 1px solid var(--border);
}
.mode-tab.active {
background: #2563eb;
color: #fff;
}
.mode-tab:hover:not(.active) {
background: #eef2f7;
}
.filter-textarea {
resize: vertical;
font-family: monospace;
line-height: 1.5;
}
.supplementary-panel {
border-top: 1px solid var(--border);
padding: 16px 18px;
}
.supplementary-header {
font-size: 12px;
font-weight: 700;
color: #64748b;
margin-bottom: 12px;
}
.supplementary-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
align-items: end;
}
.resolution-info {
border-top: 1px solid var(--border);
font-size: 13px;
color: #0f766e;
font-weight: 600;
}
.resolution-warn {
color: #b45309;
font-weight: 400;
}
.card {
background: var(--card-bg);
border-radius: 10px;
box-shadow: var(--shadow);
overflow: hidden;
overflow: visible;
margin-bottom: 14px;
}
@@ -14,6 +84,7 @@
padding: 14px 18px;
border-bottom: 1px solid var(--border);
background: #f8fafc;
border-radius: 10px 10px 0 0;
}
.card-title {
@@ -515,6 +586,10 @@
grid-column: span 2;
}
.supplementary-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pareto-layout {
grid-template-columns: 1fr;
}
@@ -533,6 +608,10 @@
grid-column: span 1;
}
.supplementary-row {
grid-template-columns: 1fr;
}
.filter-toolbar {
flex-direction: column;
align-items: flex-start;