fix(query-tool): batch detail loading, UX polish, and docs cleanup

- Fix multi-WO display: auto-select all tree roots after resolve so detail
  panel loads data for every work order, not just the first seed CID
- Disable scroll-wheel zoom on lineage tree (roam: 'move') to prevent
  accidental layout jumps while preserving drag-pan
- Add batch API endpoints (get_lot_history_batch, get_lot_associations_batch)
  to avoid N parallel requests hitting rate limits
- Remove redundant Split sub-tab from LOT detail (tree already shows splits)
- Rename 退貨 → 報廢 to match actual reject/scrap data semantics
- Hide internal ID columns (CONTAINERID, EQUIPMENTID, RESOURCEID) from
  history table display
- Add timeline scroll container and time range header for long timelines
- Remove obsolete migration and architecture docs no longer needed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-13 17:42:11 +08:00
parent 5b358d71c1
commit 248cbc25e0
119 changed files with 842 additions and 9085 deletions

View File

@@ -194,19 +194,15 @@ async function handleResolveLots() {
await lotLineage.primeResolvedLots(lotResolve.resolvedLots.value);
const rootIds = lotLineage.rootContainerIds.value;
if (rootIds.length === 0) {
const treeRootIds = lotLineage.treeRoots.value;
if (treeRootIds.length === 0) {
await lotDetail.setSelectedContainerId('');
lotLineage.clearSelection();
return;
}
const preferredSelection = lotDetail.selectedContainerId.value && rootIds.includes(lotDetail.selectedContainerId.value)
? lotDetail.selectedContainerId.value
: rootIds[0];
lotLineage.selectNode(preferredSelection);
await lotDetail.setSelectedContainerId(preferredSelection);
// Auto-select all tree roots and load detail for their full subtrees
await handleSelectNodes(treeRootIds);
}
async function handleSelectNodes(containerIds) {

View File

@@ -216,8 +216,7 @@ const chartOption = computed(() => {
orient: 'LR',
expandAndCollapse: true,
initialTreeDepth: -1,
roam: true,
scaleLimit: { min: 0.5, max: 3 },
roam: 'move',
symbol: 'circle',
symbolSize: 10,
label: {

View File

@@ -70,9 +70,8 @@ const emit = defineEmits(['change-sub-tab', 'update-workcenter-groups', 'export-
const tabMeta = Object.freeze({
history: { label: '歷程', emptyText: '無歷程資料' },
materials: { label: '物料', emptyText: '無物料資料' },
rejects: { label: '退貨', emptyText: '無退貨資料' },
rejects: { label: '報廢', emptyText: '無報廢資料' },
holds: { label: 'Hold', emptyText: '無 Hold 資料' },
splits: { label: 'Split', emptyText: '無 Split 資料' },
jobs: { label: 'Job', emptyText: '無 Job 資料' },
});

View File

@@ -25,7 +25,11 @@ const props = defineProps({
const emit = defineEmits(['update:workcenterGroups']);
const columns = computed(() => Object.keys(props.rows[0] || {}));
const HIDDEN_COLUMNS = new Set(['CONTAINERID', 'EQUIPMENTID', 'RESOURCEID']);
const columns = computed(() =>
Object.keys(props.rows[0] || {}).filter((col) => !HIDDEN_COLUMNS.has(col)),
);
const workcenterOptions = computed(() => {
return props.workcenterGroups.map((group) => {

View File

@@ -2,7 +2,7 @@
import { computed } from 'vue';
import TimelineChart from '../../shared-ui/components/TimelineChart.vue';
import { hashColor, normalizeText, parseDateTime } from '../utils/values.js';
import { formatDateTime, hashColor, normalizeText, parseDateTime } from '../utils/values.js';
const props = defineProps({
historyRows: {
@@ -151,24 +151,28 @@ const timeRange = computed(() => {
<template>
<section class="rounded-card border border-stroke-soft bg-white p-3">
<div class="mb-2 flex items-center justify-between">
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<h4 class="text-sm font-semibold text-slate-800">LOT 生產 Timeline</h4>
<p class="text-xs text-slate-500">Hold / Material 事件已覆蓋標記</p>
<div class="flex items-center gap-3 text-xs text-slate-500">
<span v-if="timeRange">{{ formatDateTime(timeRange.start) }} {{ formatDateTime(timeRange.end) }}</span>
<span>Hold / Material 事件已覆蓋標記</span>
</div>
</div>
<div v-if="tracks.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
歷程資料不足無法產生 Timeline
</div>
<TimelineChart
v-else
:tracks="tracks"
:events="events"
:time-range="timeRange"
:color-map="colorMap"
:label-width="180"
:track-row-height="46"
:min-chart-width="1040"
/>
<div v-else class="max-h-[420px] overflow-y-auto rounded-card border border-stroke-soft">
<TimelineChart
:tracks="tracks"
:events="events"
:time-range="timeRange"
:color-map="colorMap"
:label-width="180"
:track-row-height="46"
:min-chart-width="1040"
/>
</div>
</section>
</template>

View File

@@ -9,18 +9,16 @@ const LOT_SUB_TABS = Object.freeze([
'materials',
'rejects',
'holds',
'splits',
'jobs',
]);
const ASSOCIATION_TABS = new Set(['materials', 'rejects', 'holds', 'splits', 'jobs']);
const ASSOCIATION_TABS = new Set(['materials', 'rejects', 'holds', 'jobs']);
const EXPORT_TYPE_MAP = Object.freeze({
history: 'lot_history',
materials: 'lot_materials',
rejects: 'lot_rejects',
holds: 'lot_holds',
splits: 'lot_splits',
jobs: 'lot_jobs',
});
@@ -30,7 +28,6 @@ function emptyTabFlags() {
materials: false,
rejects: false,
holds: false,
splits: false,
jobs: false,
};
}
@@ -42,7 +39,6 @@ function emptyTabErrors() {
materials: '',
rejects: '',
holds: '',
splits: '',
jobs: '',
};
}
@@ -52,7 +48,6 @@ function emptyAssociations() {
materials: [],
rejects: [],
holds: [],
splits: [],
jobs: [],
};
}
@@ -62,41 +57,6 @@ function normalizeSubTab(value) {
return LOT_SUB_TABS.includes(tab) ? tab : 'history';
}
function flattenSplitPayload(payload) {
if (Array.isArray(payload?.data)) {
return payload.data;
}
const productionHistory = Array.isArray(payload?.production_history)
? payload.production_history.map((item) => ({
RECORD_TYPE: 'PRODUCTION_HISTORY',
...item,
}))
: [];
const serialRows = Array.isArray(payload?.serial_numbers)
? payload.serial_numbers.flatMap((item) => {
const serialNumber = item?.serial_number || '';
const totalGoodDie = item?.total_good_die || null;
const lots = Array.isArray(item?.lots) ? item.lots : [];
return lots.map((lot) => ({
RECORD_TYPE: 'SERIAL_MAPPING',
SERIAL_NUMBER: serialNumber,
TOTAL_GOOD_DIE: totalGoodDie,
LOT_ID: lot?.lot_id || '',
WORK_ORDER: lot?.work_order || '',
COMBINE_RATIO: lot?.combine_ratio,
COMBINE_RATIO_PCT: lot?.combine_ratio_pct || '',
GOOD_DIE_QTY: lot?.good_die_qty,
ORIGINAL_START_DATE: lot?.original_start_date,
}));
})
: [];
return [...productionHistory, ...serialRows];
}
function resolveTimeRangeFromHistory(rows) {
if (!Array.isArray(rows) || rows.length === 0) {
return null;
@@ -168,7 +128,6 @@ export function useLotDetail(initial = {}) {
materials: false,
rejects: false,
holds: false,
splits: false,
jobs: false,
});
@@ -234,38 +193,24 @@ export function useLotDetail(initial = {}) {
errors.history = '';
try {
const results = await Promise.allSettled(
cids.map((cid) => {
const params = new URLSearchParams();
params.set('container_id', cid);
if (selectedWorkcenterGroups.value.length > 0) {
params.set('workcenter_groups', selectedWorkcenterGroups.value.join(','));
}
return apiGet(`/api/query-tool/lot-history?${params.toString()}`, {
timeout: 60000,
silent: true,
});
}),
);
const allRows = [];
const failedCids = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
const rows = Array.isArray(result.value?.data) ? result.value.data : [];
allRows.push(...rows);
} else {
failedCids.push(cids[index]);
}
});
historyRows.value = allRows;
loaded.history = true;
if (failedCids.length > 0) {
errors.history = `部分節點歷程載入失敗:${failedCids.join(', ')}`;
const params = new URLSearchParams();
// Batch mode: send all CIDs in one request
if (cids.length > 1) {
params.set('container_ids', cids.join(','));
} else {
params.set('container_id', cids[0]);
}
if (selectedWorkcenterGroups.value.length > 0) {
params.set('workcenter_groups', selectedWorkcenterGroups.value.join(','));
}
const payload = await apiGet(`/api/query-tool/lot-history?${params.toString()}`, {
timeout: 120000,
silent: true,
});
historyRows.value = Array.isArray(payload?.data) ? payload.data : [];
loaded.history = true;
return true;
} catch (error) {
errors.history = error?.message || '載入 LOT 歷程失敗';
@@ -324,29 +269,21 @@ export function useLotDetail(initial = {}) {
associationRows[associationType] = Array.isArray(payload?.data) ? payload.data : [];
} else {
// Non-jobs tabs: load in parallel for all selected CIDs
const results = await Promise.allSettled(
cids.map((cid) => {
const params = new URLSearchParams();
params.set('container_id', cid);
params.set('type', associationType);
return apiGet(`/api/query-tool/lot-associations?${params.toString()}`, {
timeout: 120000,
silent: true,
});
}),
);
// Non-jobs tabs: batch all CIDs into a single request
const params = new URLSearchParams();
if (cids.length > 1) {
params.set('container_ids', cids.join(','));
} else {
params.set('container_id', cids[0]);
}
params.set('type', associationType);
const allRows = [];
results.forEach((result) => {
if (result.status === 'fulfilled') {
const rows = associationType === 'splits'
? flattenSplitPayload(result.value)
: (Array.isArray(result.value?.data) ? result.value.data : []);
allRows.push(...rows);
}
const payload = await apiGet(`/api/query-tool/lot-associations?${params.toString()}`, {
timeout: 120000,
silent: true,
});
associationRows[associationType] = allRows;
associationRows[associationType] = Array.isArray(payload?.data) ? payload.data : [];
}
loaded[associationType] = true;