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:
@@ -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
@@ -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 GA260200% ..."
|
||||
></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)"
|
||||
>
|
||||
×
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user