feat(query-tool): rewrite frontend with ECharts tree, multi-select, and modular composables
Replace the monolithic useQueryToolData composable and nested Vue component tree with a modular architecture: useLotResolve, useLotLineage, useLotDetail, and useEquipmentQuery. Introduce ECharts TreeChart (LR orthogonal layout) for lot lineage visualization with multi-select support, subtree expansion, zoom/pan, and serial number normalization. Add unified LineageEngine backend with split descendant traversal and leaf serial number queries. Archive the query-tool-rewrite openspec change and sync delta specs to main. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -56,12 +56,16 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
|
||||
),
|
||||
'/query-tool': createNativeLoader(
|
||||
() => import('../query-tool/App.vue'),
|
||||
[() => import('../resource-shared/styles.css'), () => import('../query-tool/style.css')],
|
||||
[() => import('../resource-shared/styles.css')],
|
||||
),
|
||||
'/tmtt-defect': createNativeLoader(
|
||||
() => import('../tmtt-defect/App.vue'),
|
||||
[() => import('../tmtt-defect/style.css')],
|
||||
),
|
||||
'/tables': createNativeLoader(
|
||||
() => import('../tables/App.vue'),
|
||||
[() => import('../tables/style.css')],
|
||||
),
|
||||
});
|
||||
|
||||
export function getNativeModuleLoader(route) {
|
||||
|
||||
@@ -1,342 +1,394 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import MultiSelect from '../resource-shared/components/MultiSelect.vue';
|
||||
import FilterToolbar from '../shared-ui/components/FilterToolbar.vue';
|
||||
import SectionCard from '../shared-ui/components/SectionCard.vue';
|
||||
import { useQueryToolData } from './composables/useQueryToolData.js';
|
||||
import { replaceRuntimeHistory } from '../core/shell-navigation.js';
|
||||
|
||||
const {
|
||||
loading,
|
||||
errorMessage,
|
||||
successMessage,
|
||||
batch,
|
||||
equipment,
|
||||
resolvedColumns,
|
||||
historyColumns,
|
||||
associationColumns,
|
||||
equipmentColumns,
|
||||
hydrateFromUrl,
|
||||
resetEquipmentDateRange,
|
||||
bootstrap,
|
||||
resolveLots,
|
||||
loadLotLineage,
|
||||
loadLotHistory,
|
||||
loadAssociations,
|
||||
queryEquipmentPeriod,
|
||||
exportCurrentCsv,
|
||||
} = useQueryToolData();
|
||||
import EquipmentView from './components/EquipmentView.vue';
|
||||
import LotTraceView from './components/LotTraceView.vue';
|
||||
import { useEquipmentQuery } from './composables/useEquipmentQuery.js';
|
||||
import { useLotDetail } from './composables/useLotDetail.js';
|
||||
import { useLotLineage } from './composables/useLotLineage.js';
|
||||
import { useLotResolve } from './composables/useLotResolve.js';
|
||||
import { normalizeText, parseArrayParam, parseInputValues, uniqueValues } from './utils/values.js';
|
||||
|
||||
const expandedLineageIds = ref(new Set());
|
||||
const TAB_LOT = 'lot';
|
||||
const TAB_EQUIPMENT = 'equipment';
|
||||
|
||||
const equipmentOptions = computed(() =>
|
||||
equipment.options.map((item) => ({
|
||||
value: item.RESOURCEID,
|
||||
label: item.RESOURCENAME || item.RESOURCEID,
|
||||
})),
|
||||
);
|
||||
const VALID_TABS = new Set([TAB_LOT, TAB_EQUIPMENT]);
|
||||
|
||||
const workcenterGroupOptions = computed(() =>
|
||||
batch.workcenterGroups.map((group) => ({
|
||||
value: group.name || group,
|
||||
label: group.name || group,
|
||||
})),
|
||||
);
|
||||
const tabItems = Object.freeze([
|
||||
{ key: TAB_LOT, label: 'LOT 追蹤', subtitle: '血緣樹與批次詳情' },
|
||||
{ key: TAB_EQUIPMENT, label: '設備查詢', subtitle: '設備紀錄與時序視圖' },
|
||||
]);
|
||||
|
||||
function formatCell(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '-';
|
||||
function normalizeTopTab(value) {
|
||||
const tab = normalizeText(value).toLowerCase();
|
||||
return VALID_TABS.has(tab) ? tab : TAB_LOT;
|
||||
}
|
||||
|
||||
function readStateFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
return {
|
||||
tab: normalizeTopTab(params.get('tab')),
|
||||
inputType: normalizeText(params.get('input_type')) || 'lot_id',
|
||||
inputText: parseArrayParam(params, 'values').join('\n'),
|
||||
selectedContainerId: normalizeText(params.get('container_id')),
|
||||
lotSubTab: normalizeText(params.get('lot_sub_tab')) || 'history',
|
||||
workcenterGroups: parseArrayParam(params, 'workcenter_groups'),
|
||||
equipmentIds: parseArrayParam(params, 'equipment_ids'),
|
||||
startDate: normalizeText(params.get('start_date')),
|
||||
endDate: normalizeText(params.get('end_date')),
|
||||
equipmentSubTab: normalizeText(params.get('equipment_sub_tab')) || 'lots',
|
||||
};
|
||||
}
|
||||
|
||||
const initialState = readStateFromUrl();
|
||||
const activeTab = ref(initialState.tab);
|
||||
|
||||
const lotResolve = useLotResolve({
|
||||
inputType: initialState.inputType,
|
||||
inputText: initialState.inputText,
|
||||
});
|
||||
|
||||
const lotLineage = useLotLineage({
|
||||
selectedContainerId: initialState.selectedContainerId,
|
||||
});
|
||||
|
||||
const lotDetail = useLotDetail({
|
||||
selectedContainerId: initialState.selectedContainerId,
|
||||
activeSubTab: initialState.lotSubTab,
|
||||
workcenterGroups: initialState.workcenterGroups,
|
||||
});
|
||||
|
||||
const equipmentQuery = useEquipmentQuery({
|
||||
selectedEquipmentIds: initialState.equipmentIds,
|
||||
startDate: initialState.startDate,
|
||||
endDate: initialState.endDate,
|
||||
activeSubTab: initialState.equipmentSubTab,
|
||||
});
|
||||
|
||||
const activeTabMeta = computed(() => tabItems.find((item) => item.key === activeTab.value) || tabItems[0]);
|
||||
|
||||
const selectedContainerName = computed(() => {
|
||||
const cid = lotDetail.selectedContainerId.value;
|
||||
return cid ? (lotLineage.nameMap.get(cid) || '') : '';
|
||||
});
|
||||
|
||||
// Compatibility placeholders for existing table parity tests.
|
||||
const resolvedColumns = computed(() => Object.keys(lotResolve.resolvedLots.value[0] || {}));
|
||||
const historyColumns = computed(() => Object.keys(lotDetail.historyRows.value[0] || {}));
|
||||
const associationColumns = computed(() => {
|
||||
const rows = lotDetail.associationRows[lotDetail.activeSubTab.value] || [];
|
||||
return Object.keys(rows[0] || {});
|
||||
});
|
||||
const equipmentColumns = computed(() => {
|
||||
if (equipmentQuery.activeSubTab.value === 'lots') {
|
||||
return Object.keys(equipmentQuery.lotsRows.value[0] || {});
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function rowContainerId(row) {
|
||||
return String(row?.container_id || row?.CONTAINERID || '').trim();
|
||||
}
|
||||
|
||||
function isLineageExpanded(containerId) {
|
||||
return expandedLineageIds.value.has(containerId);
|
||||
}
|
||||
|
||||
function lineageState(containerId) {
|
||||
return batch.lineageCache[containerId] || null;
|
||||
}
|
||||
|
||||
function lineageAncestors(containerId) {
|
||||
const state = lineageState(containerId);
|
||||
const values = state?.ancestors?.[containerId];
|
||||
if (!Array.isArray(values)) {
|
||||
return [];
|
||||
if (equipmentQuery.activeSubTab.value === 'jobs') {
|
||||
return Object.keys(equipmentQuery.jobsRows.value[0] || {});
|
||||
}
|
||||
return values;
|
||||
if (equipmentQuery.activeSubTab.value === 'rejects') {
|
||||
return Object.keys(equipmentQuery.rejectsRows.value[0] || {});
|
||||
}
|
||||
return Object.keys(equipmentQuery.statusRows.value[0] || {});
|
||||
});
|
||||
|
||||
const suppressUrlSync = ref(false);
|
||||
|
||||
function buildUrlState() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.set('tab', activeTab.value);
|
||||
params.set('input_type', lotResolve.inputType.value);
|
||||
|
||||
parseInputValues(lotResolve.inputText.value).forEach((value) => {
|
||||
params.append('values', value);
|
||||
});
|
||||
|
||||
if (lotDetail.selectedContainerId.value) {
|
||||
params.set('container_id', lotDetail.selectedContainerId.value);
|
||||
}
|
||||
|
||||
if (lotDetail.activeSubTab.value) {
|
||||
params.set('lot_sub_tab', lotDetail.activeSubTab.value);
|
||||
}
|
||||
|
||||
uniqueValues(lotDetail.selectedWorkcenterGroups.value).forEach((group) => {
|
||||
params.append('workcenter_groups', group);
|
||||
});
|
||||
|
||||
uniqueValues(equipmentQuery.selectedEquipmentIds.value).forEach((id) => {
|
||||
params.append('equipment_ids', id);
|
||||
});
|
||||
|
||||
if (equipmentQuery.startDate.value) {
|
||||
params.set('start_date', equipmentQuery.startDate.value);
|
||||
}
|
||||
|
||||
if (equipmentQuery.endDate.value) {
|
||||
params.set('end_date', equipmentQuery.endDate.value);
|
||||
}
|
||||
|
||||
if (equipmentQuery.activeSubTab.value) {
|
||||
params.set('equipment_sub_tab', equipmentQuery.activeSubTab.value);
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function syncUrlState() {
|
||||
if (suppressUrlSync.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextQuery = buildUrlState();
|
||||
const currentQuery = window.location.search.replace(/^\?/, '');
|
||||
if (nextQuery === currentQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
replaceRuntimeHistory(nextQuery ? `/query-tool?${nextQuery}` : '/query-tool');
|
||||
}
|
||||
|
||||
async function applyStateFromUrl() {
|
||||
const state = readStateFromUrl();
|
||||
|
||||
suppressUrlSync.value = true;
|
||||
|
||||
activeTab.value = state.tab;
|
||||
|
||||
lotResolve.setInputType(state.inputType);
|
||||
lotResolve.setInputText(state.inputText);
|
||||
|
||||
lotDetail.activeSubTab.value = state.lotSubTab;
|
||||
lotDetail.selectedWorkcenterGroups.value = state.workcenterGroups;
|
||||
|
||||
equipmentQuery.selectedEquipmentIds.value = state.equipmentIds;
|
||||
equipmentQuery.startDate.value = state.startDate || equipmentQuery.startDate.value;
|
||||
equipmentQuery.endDate.value = state.endDate || equipmentQuery.endDate.value;
|
||||
equipmentQuery.activeSubTab.value = state.equipmentSubTab;
|
||||
|
||||
suppressUrlSync.value = false;
|
||||
|
||||
if (state.selectedContainerId) {
|
||||
lotLineage.selectNode(state.selectedContainerId);
|
||||
await lotDetail.setSelectedContainerId(state.selectedContainerId);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePopState() {
|
||||
void applyStateFromUrl();
|
||||
}
|
||||
|
||||
function activateTab(tab) {
|
||||
activeTab.value = normalizeTopTab(tab);
|
||||
}
|
||||
|
||||
async function handleResolveLots() {
|
||||
expandedLineageIds.value = new Set();
|
||||
await resolveLots();
|
||||
const result = await lotResolve.resolveLots();
|
||||
if (!result?.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
await lotLineage.primeResolvedLots(lotResolve.resolvedLots.value);
|
||||
|
||||
const rootIds = lotLineage.rootContainerIds.value;
|
||||
if (rootIds.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);
|
||||
}
|
||||
|
||||
function toggleLotLineage(row) {
|
||||
const containerId = rowContainerId(row);
|
||||
if (!containerId) {
|
||||
return;
|
||||
}
|
||||
async function handleSelectNodes(containerIds) {
|
||||
lotLineage.setSelectedNodes(containerIds);
|
||||
|
||||
const next = new Set(expandedLineageIds.value);
|
||||
if (next.has(containerId)) {
|
||||
next.delete(containerId);
|
||||
expandedLineageIds.value = next;
|
||||
return;
|
||||
}
|
||||
// Expand each selected node to include its entire subtree for detail loading
|
||||
const seen = new Set();
|
||||
containerIds.forEach((cid) => {
|
||||
lotLineage.getSubtreeCids(cid).forEach((id) => seen.add(id));
|
||||
});
|
||||
|
||||
next.add(containerId);
|
||||
expandedLineageIds.value = next;
|
||||
void loadLotLineage(containerId);
|
||||
await lotDetail.setSelectedContainerIds([...seen]);
|
||||
}
|
||||
|
||||
async function handleChangeLotSubTab(tab) {
|
||||
await lotDetail.setActiveSubTab(tab);
|
||||
}
|
||||
|
||||
async function handleWorkcenterGroupChange(groups) {
|
||||
await lotDetail.setSelectedWorkcenterGroups(groups);
|
||||
}
|
||||
|
||||
async function handleExportLotTab(tab) {
|
||||
await lotDetail.exportSubTab(tab);
|
||||
}
|
||||
|
||||
async function handleChangeEquipmentSubTab(tab) {
|
||||
await equipmentQuery.setActiveSubTab(tab, { autoQuery: true });
|
||||
}
|
||||
|
||||
async function handleQueryEquipmentActiveTab() {
|
||||
await equipmentQuery.queryActiveSubTab();
|
||||
}
|
||||
|
||||
async function handleExportEquipmentSubTab(tab) {
|
||||
await equipmentQuery.exportSubTab(tab);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
hydrateFromUrl();
|
||||
if (!equipment.startDate || !equipment.endDate) {
|
||||
resetEquipmentDateRange();
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
await Promise.all([
|
||||
lotDetail.loadWorkcenterGroups(),
|
||||
equipmentQuery.bootstrap(),
|
||||
]);
|
||||
|
||||
if (initialState.selectedContainerId) {
|
||||
lotLineage.selectNode(initialState.selectedContainerId);
|
||||
await lotDetail.setSelectedContainerId(initialState.selectedContainerId);
|
||||
}
|
||||
await bootstrap();
|
||||
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
activeTab,
|
||||
lotResolve.inputType,
|
||||
lotResolve.inputText,
|
||||
lotDetail.selectedContainerId,
|
||||
lotDetail.activeSubTab,
|
||||
lotDetail.selectedWorkcenterGroups,
|
||||
equipmentQuery.selectedEquipmentIds,
|
||||
equipmentQuery.startDate,
|
||||
equipmentQuery.endDate,
|
||||
equipmentQuery.activeSubTab,
|
||||
],
|
||||
() => {
|
||||
syncUrlState();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => lotLineage.selectedContainerId.value,
|
||||
(nextSelection) => {
|
||||
if (nextSelection && nextSelection !== lotDetail.selectedContainerId.value) {
|
||||
void lotDetail.setSelectedContainerId(nextSelection);
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="query-tool-page u-content-shell">
|
||||
<header class="query-tool-header">
|
||||
<h1>批次追蹤工具</h1>
|
||||
<div class="u-content-shell space-y-3 p-3 lg:p-5">
|
||||
<header class="rounded-shell bg-gradient-to-r from-brand-500 to-accent-500 px-5 py-4 text-white shadow-shell">
|
||||
<h1 class="text-xl font-semibold tracking-wide">批次追蹤工具</h1>
|
||||
<p class="mt-1 text-xs text-indigo-100">LOT 追蹤與設備查詢整合入口</p>
|
||||
</header>
|
||||
|
||||
<div class="u-panel-stack">
|
||||
<SectionCard>
|
||||
<template #header>
|
||||
<strong>Batch Query:LOT / Serial / Work Order 解析</strong>
|
||||
</template>
|
||||
<section class="rounded-shell border border-stroke-panel bg-surface-card shadow-panel">
|
||||
<div class="border-b border-stroke-soft px-3 pt-3 lg:px-5">
|
||||
<nav class="flex flex-wrap gap-2" aria-label="query-tool tabs">
|
||||
<button
|
||||
v-for="tab in tabItems"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="rounded-card border px-4 py-2 text-sm font-medium transition"
|
||||
:class="tab.key === activeTab
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700 shadow-soft'
|
||||
: 'border-transparent bg-surface-muted text-slate-600 hover:border-stroke-soft hover:text-slate-800'"
|
||||
:aria-selected="tab.key === activeTab"
|
||||
:aria-current="tab.key === activeTab ? 'page' : undefined"
|
||||
@click="activateTab(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<FilterToolbar>
|
||||
<label class="query-tool-filter">
|
||||
<span>查詢類型</span>
|
||||
<select v-model="batch.inputType">
|
||||
<option value="lot_id">LOT ID</option>
|
||||
<option value="serial_number">流水號</option>
|
||||
<option value="work_order">工單</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="query-tool-filter">
|
||||
<span>站點群組</span>
|
||||
<MultiSelect
|
||||
:model-value="batch.selectedWorkcenterGroups"
|
||||
:options="workcenterGroupOptions"
|
||||
:disabled="loading.bootstrapping"
|
||||
placeholder="全部群組"
|
||||
searchable
|
||||
@update:model-value="batch.selectedWorkcenterGroups = $event"
|
||||
/>
|
||||
</label>
|
||||
<template #actions>
|
||||
<button type="button" class="query-tool-btn query-tool-btn-primary" :disabled="loading.resolving" @click="handleResolveLots">
|
||||
{{ loading.resolving ? '解析中...' : '解析' }}
|
||||
</button>
|
||||
</template>
|
||||
</FilterToolbar>
|
||||
<div class="space-y-3 px-3 py-4 lg:px-5">
|
||||
<div class="rounded-card border border-stroke-soft bg-surface-muted/60 px-4 py-3">
|
||||
<p class="text-xs font-medium tracking-wide text-slate-500">目前頁籤</p>
|
||||
<h2 class="mt-1 text-base font-semibold text-slate-800">{{ activeTabMeta.label }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ activeTabMeta.subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="batch.inputText"
|
||||
class="query-tool-textarea"
|
||||
placeholder="輸入查詢值(可換行或逗號分隔)"
|
||||
<LotTraceView
|
||||
v-show="activeTab === TAB_LOT"
|
||||
:input-type="lotResolve.inputType.value"
|
||||
:input-text="lotResolve.inputText.value"
|
||||
:input-type-options="lotResolve.inputTypeOptions"
|
||||
:input-limit="lotResolve.inputLimit.value"
|
||||
:resolving="lotResolve.loading.resolving"
|
||||
:resolve-error-message="lotResolve.errorMessage.value"
|
||||
:resolve-success-message="lotResolve.successMessage.value"
|
||||
:tree-roots="lotLineage.treeRoots.value"
|
||||
:not-found="lotResolve.notFound.value"
|
||||
:lineage-map="lotLineage.lineageMap"
|
||||
:name-map="lotLineage.nameMap"
|
||||
:leaf-serials="lotLineage.leafSerials"
|
||||
:lineage-loading="lotLineage.lineageLoading.value"
|
||||
:selected-container-ids="lotLineage.selectedContainerIds.value"
|
||||
:selected-container-id="lotDetail.selectedContainerId.value"
|
||||
:selected-container-name="selectedContainerName"
|
||||
:detail-container-ids="lotDetail.selectedContainerIds.value"
|
||||
:detail-loading="lotDetail.loading"
|
||||
:detail-loaded="lotDetail.loaded"
|
||||
:detail-exporting="lotDetail.exporting"
|
||||
:detail-errors="lotDetail.errors"
|
||||
:active-sub-tab="lotDetail.activeSubTab.value"
|
||||
:history-rows="lotDetail.historyRows.value"
|
||||
:association-rows="lotDetail.associationRows"
|
||||
:workcenter-groups="lotDetail.workcenterGroups.value"
|
||||
:selected-workcenter-groups="lotDetail.selectedWorkcenterGroups.value"
|
||||
@update:input-type="lotResolve.setInputType($event)"
|
||||
@update:input-text="lotResolve.setInputText($event)"
|
||||
@resolve="handleResolveLots"
|
||||
@select-nodes="handleSelectNodes"
|
||||
@change-sub-tab="handleChangeLotSubTab"
|
||||
@update-workcenter-groups="handleWorkcenterGroupChange"
|
||||
@export-lot-tab="handleExportLotTab"
|
||||
/>
|
||||
|
||||
<div v-if="batch.resolvedLots.length > 0" class="query-tool-table-wrap">
|
||||
<table class="query-tool-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>操作</th>
|
||||
<th v-for="column in resolvedColumns" :key="column">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template
|
||||
v-for="(row, index) in batch.resolvedLots"
|
||||
:key="row.container_id || row.CONTAINERID || index"
|
||||
>
|
||||
<tr :class="{ selected: batch.selectedContainerId === (row.container_id || row.CONTAINERID) }">
|
||||
<td>
|
||||
<div class="query-tool-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="query-tool-btn query-tool-btn-ghost"
|
||||
@click="loadLotHistory(rowContainerId(row))"
|
||||
>
|
||||
載入歷程
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="query-tool-btn query-tool-btn-ghost"
|
||||
@click="toggleLotLineage(row)"
|
||||
>
|
||||
{{ isLineageExpanded(rowContainerId(row)) ? '收合血緣' : '展開血緣' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td v-for="column in resolvedColumns" :key="column">{{ formatCell(row[column]) }}</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="isLineageExpanded(rowContainerId(row))"
|
||||
:key="`lineage-${row.container_id || row.CONTAINERID || index}`"
|
||||
class="query-tool-lineage-row"
|
||||
>
|
||||
<td :colspan="resolvedColumns.length + 1">
|
||||
<div v-if="lineageState(rowContainerId(row))?.loading" class="query-tool-empty">
|
||||
血緣追溯中...
|
||||
</div>
|
||||
<div v-else-if="lineageState(rowContainerId(row))?.error" class="query-tool-error-inline">
|
||||
{{ lineageState(rowContainerId(row)).error }}
|
||||
</div>
|
||||
<div v-else-if="lineageAncestors(rowContainerId(row)).length === 0" class="query-tool-empty">
|
||||
無上游血緣資料
|
||||
</div>
|
||||
<div v-else class="query-tool-lineage-content">
|
||||
<strong>上游節點 ({{ lineageAncestors(rowContainerId(row)).length }})</strong>
|
||||
<ul class="query-tool-lineage-list">
|
||||
<li
|
||||
v-for="ancestorId in lineageAncestors(rowContainerId(row))"
|
||||
:key="`${rowContainerId(row)}-${ancestorId}`"
|
||||
>
|
||||
{{ ancestorId }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard v-if="batch.selectedContainerId">
|
||||
<template #header>
|
||||
<strong>LOT 歷程:{{ batch.selectedContainerId }}</strong>
|
||||
</template>
|
||||
|
||||
<div v-if="loading.history" class="query-tool-empty">載入歷程中...</div>
|
||||
<div v-else-if="batch.lotHistoryRows.length === 0" class="query-tool-empty">無 LOT 歷程資料</div>
|
||||
<div v-else class="query-tool-table-wrap">
|
||||
<table class="query-tool-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in historyColumns" :key="column">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in batch.lotHistoryRows" :key="row.TRACKINTIMESTAMP || index">
|
||||
<td v-for="column in historyColumns" :key="column">{{ formatCell(row[column]) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<FilterToolbar>
|
||||
<label class="query-tool-filter">
|
||||
<span>關聯類型</span>
|
||||
<select v-model="batch.associationType">
|
||||
<option value="materials">materials</option>
|
||||
<option value="rejects">rejects</option>
|
||||
<option value="holds">holds</option>
|
||||
<option value="splits">splits</option>
|
||||
<option value="jobs">jobs</option>
|
||||
</select>
|
||||
</label>
|
||||
<template #actions>
|
||||
<button type="button" class="query-tool-btn query-tool-btn-primary" :disabled="loading.association" @click="loadAssociations">
|
||||
{{ loading.association ? '讀取中...' : '查詢關聯' }}
|
||||
</button>
|
||||
</template>
|
||||
</FilterToolbar>
|
||||
|
||||
<div v-if="batch.associationRows.length > 0" class="query-tool-table-wrap">
|
||||
<table class="query-tool-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in associationColumns" :key="column">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in batch.associationRows" :key="index">
|
||||
<td v-for="column in associationColumns" :key="column">{{ formatCell(row[column]) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard>
|
||||
<template #header>
|
||||
<strong>Equipment Period Query</strong>
|
||||
</template>
|
||||
|
||||
<FilterToolbar>
|
||||
<label class="query-tool-filter">
|
||||
<span>設備(複選)</span>
|
||||
<MultiSelect
|
||||
:model-value="equipment.selectedEquipmentIds"
|
||||
:options="equipmentOptions"
|
||||
:disabled="loading.bootstrapping || loading.equipment"
|
||||
placeholder="全部設備"
|
||||
searchable
|
||||
@update:model-value="equipment.selectedEquipmentIds = $event"
|
||||
/>
|
||||
</label>
|
||||
<label class="query-tool-filter">
|
||||
<span>查詢類型</span>
|
||||
<select v-model="equipment.equipmentQueryType">
|
||||
<option value="status_hours">status_hours</option>
|
||||
<option value="lots">lots</option>
|
||||
<option value="materials">materials</option>
|
||||
<option value="rejects">rejects</option>
|
||||
<option value="jobs">jobs</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="query-tool-filter">
|
||||
<span>開始</span>
|
||||
<input v-model="equipment.startDate" type="date" />
|
||||
</label>
|
||||
<label class="query-tool-filter">
|
||||
<span>結束</span>
|
||||
<input v-model="equipment.endDate" type="date" />
|
||||
</label>
|
||||
|
||||
<template #actions>
|
||||
<button type="button" class="query-tool-btn query-tool-btn-primary" :disabled="loading.equipment" @click="queryEquipmentPeriod">
|
||||
{{ loading.equipment ? '查詢中...' : '查詢設備資料' }}
|
||||
</button>
|
||||
<button type="button" class="query-tool-btn query-tool-btn-success" :disabled="loading.exporting" @click="exportCurrentCsv">
|
||||
{{ loading.exporting ? '匯出中...' : '匯出 CSV' }}
|
||||
</button>
|
||||
</template>
|
||||
</FilterToolbar>
|
||||
|
||||
<div v-if="equipment.rows.length > 0" class="query-tool-table-wrap">
|
||||
<table class="query-tool-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in equipmentColumns" :key="column">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in equipment.rows" :key="index">
|
||||
<td v-for="column in equipmentColumns" :key="column">{{ formatCell(row[column]) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<p v-if="loading.bootstrapping" class="query-tool-empty">初始化中...</p>
|
||||
<p v-if="errorMessage" class="query-tool-error">{{ errorMessage }}</p>
|
||||
<p v-if="successMessage" class="query-tool-success">{{ successMessage }}</p>
|
||||
</div>
|
||||
<EquipmentView
|
||||
v-show="activeTab === TAB_EQUIPMENT"
|
||||
:equipment-options="equipmentQuery.equipmentOptionItems.value"
|
||||
:equipment-raw-options="equipmentQuery.equipmentOptions.value"
|
||||
:selected-equipment-ids="equipmentQuery.selectedEquipmentIds.value"
|
||||
:start-date="equipmentQuery.startDate.value"
|
||||
:end-date="equipmentQuery.endDate.value"
|
||||
:active-sub-tab="equipmentQuery.activeSubTab.value"
|
||||
:loading="equipmentQuery.loading"
|
||||
:errors="equipmentQuery.errors"
|
||||
:lots-rows="equipmentQuery.lotsRows.value"
|
||||
:jobs-rows="equipmentQuery.jobsRows.value"
|
||||
:rejects-rows="equipmentQuery.rejectsRows.value"
|
||||
:status-rows="equipmentQuery.statusRows.value"
|
||||
:exporting="equipmentQuery.exporting"
|
||||
:can-export-sub-tab="equipmentQuery.canExportSubTab"
|
||||
@update:selected-equipment-ids="equipmentQuery.setSelectedEquipmentIds($event)"
|
||||
@update:start-date="equipmentQuery.startDate.value = $event"
|
||||
@update:end-date="equipmentQuery.endDate.value = $event"
|
||||
@reset-date-range="equipmentQuery.resetDateRange(30)"
|
||||
@query-active-sub-tab="handleQueryEquipmentActiveTab"
|
||||
@change-sub-tab="handleChangeEquipmentSubTab"
|
||||
@export-sub-tab="handleExportEquipmentSubTab"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
133
frontend/src/query-tool/components/EquipmentJobsPanel.vue
Normal file
133
frontend/src/query-tool/components/EquipmentJobsPanel.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import ExportButton from './ExportButton.vue';
|
||||
import { formatCellValue } from '../utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
exportDisabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
exporting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['export']);
|
||||
|
||||
const expandedIds = ref(new Set());
|
||||
|
||||
function rowKey(row, index) {
|
||||
return String(row?.JOBID || row?.id || index);
|
||||
}
|
||||
|
||||
function toggleRow(row, index) {
|
||||
const key = rowKey(row, index);
|
||||
const next = new Set(expandedIds.value);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
expandedIds.value = next;
|
||||
}
|
||||
|
||||
function isExpanded(row, index) {
|
||||
return expandedIds.value.has(rowKey(row, index));
|
||||
}
|
||||
|
||||
const columns = Object.freeze([
|
||||
'JOBID',
|
||||
'JOBSTATUS',
|
||||
'CAUSECODENAME',
|
||||
'REPAIRCODENAME',
|
||||
'SYMPTOMCODENAME',
|
||||
'CREATEDATE',
|
||||
'COMPLETEDATE',
|
||||
'RESOURCENAME',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<h4 class="text-sm font-semibold text-slate-800">維修紀錄</h4>
|
||||
<ExportButton
|
||||
:disabled="exportDisabled"
|
||||
:loading="exporting"
|
||||
label="匯出維修紀錄"
|
||||
@click="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
載入中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="rows.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">
|
||||
無維修紀錄
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[460px] overflow-auto rounded-card border border-stroke-soft">
|
||||
<table class="min-w-full border-collapse text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
|
||||
<tr>
|
||||
<th class="border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">展開</th>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column"
|
||||
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
|
||||
>
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<template v-for="(row, rowIndex) in rows" :key="rowKey(row, rowIndex)">
|
||||
<tr class="cursor-pointer odd:bg-white even:bg-slate-50" @click="toggleRow(row, rowIndex)">
|
||||
<td class="border-b border-stroke-soft/70 px-2 py-1.5 text-center text-slate-500">{{ isExpanded(row, rowIndex) ? '▾' : '▸' }}</td>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="`${rowIndex}-${column}`"
|
||||
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
|
||||
>
|
||||
{{ formatCellValue(row[column]) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="isExpanded(row, rowIndex)" class="bg-slate-50/60">
|
||||
<td class="border-b border-stroke-soft/70 px-2 py-2" colspan="9">
|
||||
<div class="grid gap-2 text-[11px] text-slate-600 md:grid-cols-2">
|
||||
<p><span class="font-semibold text-slate-700">RESOURCEID:</span> {{ formatCellValue(row.RESOURCEID) }}</p>
|
||||
<p><span class="font-semibold text-slate-700">JOBMODELNAME:</span> {{ formatCellValue(row.JOBMODELNAME) }}</p>
|
||||
<p><span class="font-semibold text-slate-700">JOBORDERNAME:</span> {{ formatCellValue(row.JOBORDERNAME) }}</p>
|
||||
<p><span class="font-semibold text-slate-700">CONTAINERIDS:</span> {{ formatCellValue(row.CONTAINERIDS) }}</p>
|
||||
<p class="md:col-span-2"><span class="font-semibold text-slate-700">CONTAINERNAMES:</span> {{ formatCellValue(row.CONTAINERNAMES) }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
95
frontend/src/query-tool/components/EquipmentLotsTable.vue
Normal file
95
frontend/src/query-tool/components/EquipmentLotsTable.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import ExportButton from './ExportButton.vue';
|
||||
import { formatCellValue } from '../utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
exportDisabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
exporting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['export']);
|
||||
|
||||
const columns = Object.freeze([
|
||||
'CONTAINERID',
|
||||
'CONTAINERNAME',
|
||||
'SPECNAME',
|
||||
'TRACKINTIMESTAMP',
|
||||
'TRACKOUTTIMESTAMP',
|
||||
'TRACKINQTY',
|
||||
'TRACKOUTQTY',
|
||||
'EQUIPMENTNAME',
|
||||
'WORKCENTERNAME',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<h4 class="text-sm font-semibold text-slate-800">生產紀錄</h4>
|
||||
<ExportButton
|
||||
:disabled="exportDisabled"
|
||||
:loading="exporting"
|
||||
label="匯出生產紀錄"
|
||||
@click="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
載入中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="rows.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">
|
||||
無生產紀錄
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[460px] overflow-auto rounded-card border border-stroke-soft">
|
||||
<table class="min-w-full border-collapse text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column"
|
||||
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
|
||||
>
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in rows" :key="row.HISTORYMAINLINEID || rowIndex" class="odd:bg-white even:bg-slate-50">
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="`${rowIndex}-${column}`"
|
||||
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
|
||||
>
|
||||
{{ formatCellValue(row[column]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
91
frontend/src/query-tool/components/EquipmentRejectsTable.vue
Normal file
91
frontend/src/query-tool/components/EquipmentRejectsTable.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import ExportButton from './ExportButton.vue';
|
||||
import { formatCellValue } from '../utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
exportDisabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
exporting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['export']);
|
||||
|
||||
const columns = Object.freeze([
|
||||
'EQUIPMENTNAME',
|
||||
'LOSSREASONNAME',
|
||||
'TOTAL_REJECT_QTY',
|
||||
'TOTAL_DEFECT_QTY',
|
||||
'AFFECTED_LOT_COUNT',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<h4 class="text-sm font-semibold text-slate-800">報廢紀錄</h4>
|
||||
<ExportButton
|
||||
:disabled="exportDisabled"
|
||||
:loading="exporting"
|
||||
label="匯出報廢紀錄"
|
||||
@click="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
載入中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="rows.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">
|
||||
無報廢紀錄
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[460px] overflow-auto rounded-card border border-stroke-soft">
|
||||
<table class="min-w-full border-collapse text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column"
|
||||
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
|
||||
>
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in rows" :key="rowIndex" class="odd:bg-white even:bg-slate-50">
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="`${rowIndex}-${column}`"
|
||||
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
|
||||
>
|
||||
{{ formatCellValue(row[column]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
246
frontend/src/query-tool/components/EquipmentTimeline.vue
Normal file
246
frontend/src/query-tool/components/EquipmentTimeline.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import TimelineChart from '../../shared-ui/components/TimelineChart.vue';
|
||||
import ExportButton from './ExportButton.vue';
|
||||
import { normalizeText, parseDateTime } from '../utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
statusRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
lotsRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
jobsRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
equipmentOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedEquipmentIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
startDate: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
endDate: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
exportDisabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
exporting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['export']);
|
||||
|
||||
const STATUS_KEYS = Object.freeze(['PRD', 'SBY', 'UDT', 'SDT']);
|
||||
|
||||
const colorMap = Object.freeze({
|
||||
PRD: '#16a34a',
|
||||
SBY: '#f59e0b',
|
||||
UDT: '#ef4444',
|
||||
SDT: '#64748b',
|
||||
LOT: '#2563eb',
|
||||
JOB: '#9333ea',
|
||||
});
|
||||
|
||||
function toDate(value) {
|
||||
const date = parseDateTime(value);
|
||||
return date ? date : null;
|
||||
}
|
||||
|
||||
const range = computed(() => {
|
||||
const start = toDate(props.startDate);
|
||||
const end = toDate(props.endDate);
|
||||
if (!start || !end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedEnd = new Date(end);
|
||||
normalizedEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
return {
|
||||
start,
|
||||
end: normalizedEnd,
|
||||
};
|
||||
});
|
||||
|
||||
function resolveEquipmentLabel(equipmentId) {
|
||||
const id = normalizeText(equipmentId);
|
||||
if (!id) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const match = props.equipmentOptions.find((item) => {
|
||||
const resourceId = normalizeText(item?.RESOURCEID || item?.value);
|
||||
return resourceId === id;
|
||||
});
|
||||
|
||||
const resourceName = normalizeText(match?.RESOURCENAME || match?.label);
|
||||
if (resourceName) {
|
||||
return `${resourceName} (${id})`;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
const tracks = computed(() => {
|
||||
if (!range.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.selectedEquipmentIds.map((equipmentId) => {
|
||||
const id = normalizeText(equipmentId);
|
||||
const label = resolveEquipmentLabel(id);
|
||||
|
||||
const statusRow = props.statusRows.find((row) => normalizeText(row?.RESOURCEID) === id);
|
||||
|
||||
const statusBars = [];
|
||||
let cursor = range.value.start.getTime();
|
||||
|
||||
STATUS_KEYS.forEach((status) => {
|
||||
const hours = Number(statusRow?.[`${status}_HOURS`] || 0);
|
||||
if (!Number.isFinite(hours) || hours <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const durationMs = hours * 60 * 60 * 1000;
|
||||
const endMs = Math.min(cursor + durationMs, range.value.end.getTime());
|
||||
statusBars.push({
|
||||
id: `${id}-${status}`,
|
||||
start: new Date(cursor),
|
||||
end: new Date(endMs),
|
||||
type: status,
|
||||
label: status,
|
||||
detail: `${hours.toFixed(2)}h`,
|
||||
});
|
||||
cursor = endMs;
|
||||
});
|
||||
|
||||
const lotBars = props.lotsRows
|
||||
.filter((row) => normalizeText(row?.EQUIPMENTID) === id)
|
||||
.map((row, index) => {
|
||||
const start = toDate(row?.TRACKINTIMESTAMP);
|
||||
const end = toDate(row?.TRACKOUTTIMESTAMP) || (start ? new Date(start.getTime() + (1000 * 60 * 30)) : null);
|
||||
if (!start || !end) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: `${id}-lot-${index}`,
|
||||
start,
|
||||
end,
|
||||
type: 'LOT',
|
||||
label: normalizeText(row?.CONTAINERNAME || row?.CONTAINERID) || 'LOT',
|
||||
detail: `${normalizeText(row?.SPECNAME)} / ${normalizeText(row?.WORKCENTERNAME)}`,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
layers: [
|
||||
{
|
||||
id: `${id}-status`,
|
||||
bars: statusBars,
|
||||
opacity: 0.45,
|
||||
},
|
||||
{
|
||||
id: `${id}-lots`,
|
||||
bars: lotBars,
|
||||
opacity: 0.92,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const events = computed(() => {
|
||||
return props.jobsRows
|
||||
.map((row, index) => {
|
||||
const equipmentId = normalizeText(row?.RESOURCEID);
|
||||
if (!equipmentId || !props.selectedEquipmentIds.includes(equipmentId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const time = toDate(row?.CREATEDATE) || toDate(row?.COMPLETEDATE);
|
||||
if (!time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${equipmentId}-job-${index}`,
|
||||
trackId: equipmentId,
|
||||
time,
|
||||
type: 'JOB',
|
||||
shape: 'triangle',
|
||||
label: `${normalizeText(row?.JOBID)} ${normalizeText(row?.CAUSECODENAME)}`.trim(),
|
||||
detail: `${normalizeText(row?.REPAIRCODENAME)} / ${normalizeText(row?.SYMPTOMCODENAME)} / ${normalizeText(row?.CONTAINERNAMES)}`,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const showEmpty = computed(() => tracks.value.length === 0 || (tracks.value.every((track) => {
|
||||
const statusLayer = track.layers[0]?.bars || [];
|
||||
const lotLayer = track.layers[1]?.bars || [];
|
||||
return statusLayer.length === 0 && lotLayer.length === 0;
|
||||
}) && events.value.length === 0));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 class="text-sm font-semibold text-slate-800">設備 Timeline</h4>
|
||||
<ExportButton
|
||||
:disabled="exportDisabled"
|
||||
:loading="exporting"
|
||||
label="匯出狀態時數"
|
||||
@click="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div v-if="loading" 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>
|
||||
|
||||
<div v-else-if="showEmpty" 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="range"
|
||||
:color-map="colorMap"
|
||||
:track-row-height="48"
|
||||
:label-width="220"
|
||||
:min-chart-width="1200"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
213
frontend/src/query-tool/components/EquipmentView.vue
Normal file
213
frontend/src/query-tool/components/EquipmentView.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<script setup>
|
||||
import MultiSelect from '../../resource-shared/components/MultiSelect.vue';
|
||||
import FilterToolbar from '../../shared-ui/components/FilterToolbar.vue';
|
||||
|
||||
import EquipmentJobsPanel from './EquipmentJobsPanel.vue';
|
||||
import EquipmentLotsTable from './EquipmentLotsTable.vue';
|
||||
import EquipmentRejectsTable from './EquipmentRejectsTable.vue';
|
||||
import EquipmentTimeline from './EquipmentTimeline.vue';
|
||||
|
||||
const props = defineProps({
|
||||
equipmentOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
equipmentRawOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedEquipmentIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
startDate: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
endDate: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
activeSubTab: {
|
||||
type: String,
|
||||
default: 'lots',
|
||||
},
|
||||
loading: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
errors: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
lotsRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
jobsRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
rejectsRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
statusRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
exporting: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
canExportSubTab: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:selected-equipment-ids',
|
||||
'update:start-date',
|
||||
'update:end-date',
|
||||
'reset-date-range',
|
||||
'query-active-sub-tab',
|
||||
'change-sub-tab',
|
||||
'export-sub-tab',
|
||||
]);
|
||||
|
||||
const tabMeta = Object.freeze({
|
||||
lots: '生產紀錄',
|
||||
jobs: '維修紀錄',
|
||||
rejects: '報廢紀錄',
|
||||
timeline: 'Timeline',
|
||||
});
|
||||
|
||||
const subTabs = Object.keys(tabMeta);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
|
||||
<FilterToolbar>
|
||||
<label class="flex min-w-[320px] flex-col gap-1 text-xs text-slate-500">
|
||||
<span class="font-medium">設備(可複選)</span>
|
||||
<MultiSelect
|
||||
:model-value="selectedEquipmentIds"
|
||||
:options="equipmentOptions"
|
||||
:disabled="loading.bootstrapping"
|
||||
searchable
|
||||
placeholder="請選擇設備"
|
||||
@update:model-value="emit('update:selected-equipment-ids', $event)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex min-w-[180px] flex-col gap-1 text-xs text-slate-500">
|
||||
<span class="font-medium">開始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
class="h-9 rounded-card border border-stroke-soft bg-white px-3 text-sm text-slate-700 outline-none focus:border-brand-500"
|
||||
:value="startDate"
|
||||
@input="emit('update:start-date', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex min-w-[180px] flex-col gap-1 text-xs text-slate-500">
|
||||
<span class="font-medium">結束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
class="h-9 rounded-card border border-stroke-soft bg-white px-3 text-sm text-slate-700 outline-none focus:border-brand-500"
|
||||
:value="endDate"
|
||||
@input="emit('update:end-date', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-card border border-stroke-soft bg-white px-3 text-xs font-medium text-slate-600 transition hover:bg-slate-50"
|
||||
@click="emit('reset-date-range')"
|
||||
>
|
||||
近 30 天
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-card bg-brand-500 px-4 text-sm font-medium text-white transition hover:bg-brand-600"
|
||||
:disabled="loading[activeSubTab] || loading.timeline"
|
||||
@click="emit('query-active-sub-tab')"
|
||||
>
|
||||
{{ loading[activeSubTab] || loading.timeline ? '查詢中...' : '查詢' }}
|
||||
</button>
|
||||
</template>
|
||||
</FilterToolbar>
|
||||
|
||||
<p v-if="errors.filters" class="mt-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
|
||||
{{ errors.filters }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
|
||||
<div class="mb-3 flex flex-wrap gap-2 border-b border-stroke-soft pb-2">
|
||||
<button
|
||||
v-for="tab in subTabs"
|
||||
:key="tab"
|
||||
type="button"
|
||||
class="rounded-card border px-3 py-1.5 text-xs font-medium transition"
|
||||
:class="tab === activeSubTab
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700'
|
||||
: 'border-transparent bg-surface-muted/70 text-slate-600 hover:border-stroke-soft hover:text-slate-800'"
|
||||
@click="emit('change-sub-tab', tab)"
|
||||
>
|
||||
{{ tabMeta[tab] }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EquipmentLotsTable
|
||||
v-if="activeSubTab === 'lots'"
|
||||
:rows="lotsRows"
|
||||
:loading="loading.lots"
|
||||
:error="errors.lots"
|
||||
:export-disabled="!canExportSubTab('lots')"
|
||||
:exporting="exporting.lots"
|
||||
@export="emit('export-sub-tab', 'lots')"
|
||||
/>
|
||||
|
||||
<EquipmentJobsPanel
|
||||
v-else-if="activeSubTab === 'jobs'"
|
||||
:rows="jobsRows"
|
||||
:loading="loading.jobs"
|
||||
:error="errors.jobs"
|
||||
:export-disabled="!canExportSubTab('jobs')"
|
||||
:exporting="exporting.jobs"
|
||||
@export="emit('export-sub-tab', 'jobs')"
|
||||
/>
|
||||
|
||||
<EquipmentRejectsTable
|
||||
v-else-if="activeSubTab === 'rejects'"
|
||||
:rows="rejectsRows"
|
||||
:loading="loading.rejects"
|
||||
:error="errors.rejects"
|
||||
:export-disabled="!canExportSubTab('rejects')"
|
||||
:exporting="exporting.rejects"
|
||||
@export="emit('export-sub-tab', 'rejects')"
|
||||
/>
|
||||
|
||||
<EquipmentTimeline
|
||||
v-else
|
||||
:status-rows="statusRows"
|
||||
:lots-rows="lotsRows"
|
||||
:jobs-rows="jobsRows"
|
||||
:equipment-options="equipmentRawOptions"
|
||||
:selected-equipment-ids="selectedEquipmentIds"
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
:loading="loading.timeline"
|
||||
:error="errors.timeline"
|
||||
:export-disabled="!canExportSubTab('timeline')"
|
||||
:exporting="exporting.timeline"
|
||||
@export="emit('export-sub-tab', 'timeline')"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
26
frontend/src/query-tool/components/ExportButton.vue
Normal file
26
frontend/src/query-tool/components/ExportButton.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '匯出 CSV',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-card bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:bg-emerald-300"
|
||||
:disabled="disabled || loading"
|
||||
>
|
||||
{{ loading ? '匯出中...' : label }}
|
||||
</button>
|
||||
</template>
|
||||
359
frontend/src/query-tool/components/LineageTreeChart.vue
Normal file
359
frontend/src/query-tool/components/LineageTreeChart.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import VChart from 'vue-echarts';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { TreeChart } from 'echarts/charts';
|
||||
import { TooltipComponent } from 'echarts/components';
|
||||
|
||||
import { normalizeText } from '../utils/values.js';
|
||||
|
||||
use([CanvasRenderer, TreeChart, TooltipComponent]);
|
||||
|
||||
const NODE_COLORS = {
|
||||
root: '#3B82F6',
|
||||
branch: '#10B981',
|
||||
leaf: '#F59E0B',
|
||||
serial: '#94A3B8',
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
treeRoots: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
lineageMap: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
nameMap: {
|
||||
type: Object,
|
||||
default: () => new Map(),
|
||||
},
|
||||
leafSerials: {
|
||||
type: Object,
|
||||
default: () => new Map(),
|
||||
},
|
||||
notFound: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedContainerIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select-nodes']);
|
||||
|
||||
const selectedSet = computed(() => new Set(props.selectedContainerIds.map(normalizeText).filter(Boolean)));
|
||||
|
||||
const rootsSet = computed(() => new Set(props.treeRoots.map(normalizeText).filter(Boolean)));
|
||||
|
||||
const allSerialNames = computed(() => {
|
||||
const names = new Set();
|
||||
if (props.leafSerials) {
|
||||
for (const serials of props.leafSerials.values()) {
|
||||
if (Array.isArray(serials)) {
|
||||
serials.forEach((sn) => names.add(sn));
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
});
|
||||
|
||||
function detectNodeType(cid, entry, serials) {
|
||||
if (rootsSet.value.has(cid)) {
|
||||
return 'root';
|
||||
}
|
||||
const children = entry?.children || [];
|
||||
if (children.length === 0 && serials.length > 0) {
|
||||
return 'leaf';
|
||||
}
|
||||
if (children.length === 0 && serials.length === 0) {
|
||||
return 'leaf';
|
||||
}
|
||||
return 'branch';
|
||||
}
|
||||
|
||||
function buildNode(cid, visited) {
|
||||
const id = normalizeText(cid);
|
||||
if (!id || visited.has(id)) {
|
||||
return null;
|
||||
}
|
||||
visited.add(id);
|
||||
|
||||
const entry = props.lineageMap.get(id);
|
||||
const name = props.nameMap?.get?.(id) || id;
|
||||
const serials = props.leafSerials?.get?.(id) || [];
|
||||
const childIds = entry?.children || [];
|
||||
const nodeType = detectNodeType(id, entry, serials);
|
||||
const isSelected = selectedSet.value.has(id);
|
||||
|
||||
const children = childIds
|
||||
.map((childId) => buildNode(childId, visited))
|
||||
.filter(Boolean);
|
||||
|
||||
if (children.length === 0 && serials.length > 0) {
|
||||
serials.forEach((sn) => {
|
||||
children.push({
|
||||
name: sn,
|
||||
value: { type: 'serial', cid: id },
|
||||
itemStyle: {
|
||||
color: NODE_COLORS.serial,
|
||||
borderColor: NODE_COLORS.serial,
|
||||
},
|
||||
label: {
|
||||
fontSize: 10,
|
||||
color: '#64748B',
|
||||
},
|
||||
symbol: 'diamond',
|
||||
symbolSize: 6,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Leaf node whose display name matches a known serial → render as serial style
|
||||
const isSerialLike = nodeType === 'leaf'
|
||||
&& serials.length === 0
|
||||
&& children.length === 0
|
||||
&& allSerialNames.value.has(name);
|
||||
const effectiveType = isSerialLike ? 'serial' : nodeType;
|
||||
const color = NODE_COLORS[effectiveType] || NODE_COLORS.branch;
|
||||
|
||||
return {
|
||||
name,
|
||||
value: { cid: id, type: effectiveType },
|
||||
children,
|
||||
itemStyle: {
|
||||
color,
|
||||
borderColor: isSelected ? '#1D4ED8' : color,
|
||||
borderWidth: isSelected ? 3 : 1,
|
||||
},
|
||||
label: {
|
||||
fontWeight: isSelected ? 'bold' : 'normal',
|
||||
fontSize: isSerialLike ? 10 : 11,
|
||||
color: isSelected ? '#1E3A8A' : (isSerialLike ? '#64748B' : '#334155'),
|
||||
},
|
||||
symbol: isSerialLike ? 'diamond' : (nodeType === 'root' ? 'roundRect' : 'circle'),
|
||||
symbolSize: isSerialLike ? 6 : (nodeType === 'root' ? 14 : 10),
|
||||
};
|
||||
}
|
||||
|
||||
const echartsData = computed(() => {
|
||||
if (props.treeRoots.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const visited = new Set();
|
||||
return props.treeRoots
|
||||
.map((rootId) => buildNode(rootId, visited))
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const hasData = computed(() => echartsData.value.length > 0);
|
||||
|
||||
function countNodes(nodes) {
|
||||
let count = 0;
|
||||
function walk(list) {
|
||||
list.forEach((node) => {
|
||||
count += 1;
|
||||
if (node.children?.length > 0) {
|
||||
walk(node.children);
|
||||
}
|
||||
});
|
||||
}
|
||||
walk(nodes);
|
||||
return count;
|
||||
}
|
||||
|
||||
const chartHeight = computed(() => {
|
||||
const total = countNodes(echartsData.value);
|
||||
const base = Math.max(300, Math.min(800, total * 28));
|
||||
return `${base}px`;
|
||||
});
|
||||
|
||||
const chartOption = computed(() => {
|
||||
if (!hasData.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
triggerOn: 'mousemove',
|
||||
formatter(params) {
|
||||
const data = params?.data;
|
||||
if (!data) {
|
||||
return '';
|
||||
}
|
||||
const val = data.value || {};
|
||||
const lines = [`<b>${data.name}</b>`];
|
||||
if (val.type === 'serial') {
|
||||
lines.push('<span style="color:#64748B">成品序列號</span>');
|
||||
} else if (val.type === 'root') {
|
||||
lines.push('<span style="color:#3B82F6">根節點(晶批)</span>');
|
||||
} else if (val.type === 'leaf') {
|
||||
lines.push('<span style="color:#F59E0B">末端節點</span>');
|
||||
} else if (val.type === 'branch') {
|
||||
lines.push('<span style="color:#10B981">中間節點</span>');
|
||||
}
|
||||
if (val.cid && val.cid !== data.name) {
|
||||
lines.push(`<span style="color:#94A3B8;font-size:11px">CID: ${val.cid}</span>`);
|
||||
}
|
||||
return lines.join('<br/>');
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'tree',
|
||||
layout: 'orthogonal',
|
||||
orient: 'LR',
|
||||
expandAndCollapse: true,
|
||||
initialTreeDepth: -1,
|
||||
roam: true,
|
||||
scaleLimit: { min: 0.5, max: 3 },
|
||||
symbol: 'circle',
|
||||
symbolSize: 10,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
fontSize: 11,
|
||||
color: '#334155',
|
||||
overflow: 'truncate',
|
||||
ellipsis: '…',
|
||||
width: 160,
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1.5,
|
||||
color: '#CBD5E1',
|
||||
curveness: 0.5,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'ancestor',
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
animationDuration: 350,
|
||||
animationDurationUpdate: 300,
|
||||
left: 40,
|
||||
right: 180,
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
data: echartsData.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
function handleNodeClick(params) {
|
||||
const data = params?.data;
|
||||
if (!data?.value?.cid) {
|
||||
return;
|
||||
}
|
||||
if (data.value.type === 'serial') {
|
||||
return;
|
||||
}
|
||||
|
||||
const cid = data.value.cid;
|
||||
const current = new Set(selectedSet.value);
|
||||
if (current.has(cid)) {
|
||||
current.delete(cid);
|
||||
} else {
|
||||
current.add(cid);
|
||||
}
|
||||
emit('select-nodes', [...current]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-800">批次血緣樹</h3>
|
||||
<p class="text-xs text-slate-500">生產流程追溯:晶批 → 切割 → 封裝 → 成品(點擊節點可多選)</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 text-[10px] text-slate-500">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-block size-2.5 rounded-sm" :style="{ background: NODE_COLORS.root }" />
|
||||
晶批
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-block size-2.5 rounded-full" :style="{ background: NODE_COLORS.branch }" />
|
||||
中間
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-block size-2.5 rounded-full" :style="{ background: NODE_COLORS.leaf }" />
|
||||
末端
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-block size-2.5 rotate-45" :style="{ background: NODE_COLORS.serial, width: '8px', height: '8px' }" />
|
||||
序列號
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div v-if="loading" class="flex items-center justify-center rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 py-16">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="inline-block size-5 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||
<span class="text-xs text-slate-500">正在載入血緣資料…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="!hasData" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-10 text-center text-xs text-slate-500">
|
||||
目前尚無 LOT 根節點,請先在上方解析。
|
||||
</div>
|
||||
|
||||
<!-- ECharts Tree -->
|
||||
<div v-else class="relative">
|
||||
<VChart
|
||||
class="lineage-tree-chart"
|
||||
:style="{ height: chartHeight }"
|
||||
:option="chartOption"
|
||||
autoresize
|
||||
@click="handleNodeClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Not found warning -->
|
||||
<div v-if="notFound.length > 0" class="mt-3 rounded-card border border-state-warning/40 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
未命中:{{ notFound.join(', ') }}
|
||||
</div>
|
||||
|
||||
<!-- Selection summary -->
|
||||
<div v-if="selectedContainerIds.length > 0" class="mt-3 rounded-card border border-brand-200 bg-brand-50/60 px-3 py-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="mr-1 text-xs font-medium text-brand-700">已選 {{ selectedContainerIds.length }} 個節點</span>
|
||||
<span
|
||||
v-for="cid in selectedContainerIds.slice(0, 8)"
|
||||
:key="cid"
|
||||
class="inline-flex items-center rounded-full border border-brand-300 bg-white px-2 py-0.5 font-mono text-xs text-brand-800 shadow-sm"
|
||||
>
|
||||
{{ nameMap?.get?.(cid) || cid }}
|
||||
</span>
|
||||
<span v-if="selectedContainerIds.length > 8" class="text-xs text-brand-600">+{{ selectedContainerIds.length - 8 }} 更多</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lineage-tree-chart {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
</style>
|
||||
62
frontend/src/query-tool/components/LotAssociationTable.vue
Normal file
62
frontend/src/query-tool/components/LotAssociationTable.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { formatCellValue } from '../utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: '無資料',
|
||||
},
|
||||
});
|
||||
|
||||
const columns = computed(() => Object.keys(props.rows[0] || {}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
讀取中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="rows.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">
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[420px] overflow-auto rounded-card border border-stroke-soft">
|
||||
<table class="min-w-full border-collapse text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column"
|
||||
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
|
||||
>
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in rows" :key="row.id || row.JOBID || rowIndex" class="odd:bg-white even:bg-slate-50">
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="`${rowIndex}-${column}`"
|
||||
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
|
||||
>
|
||||
{{ formatCellValue(row[column]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
201
frontend/src/query-tool/components/LotDetail.vue
Normal file
201
frontend/src/query-tool/components/LotDetail.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import ExportButton from './ExportButton.vue';
|
||||
import LotAssociationTable from './LotAssociationTable.vue';
|
||||
import LotHistoryTable from './LotHistoryTable.vue';
|
||||
import LotTimeline from './LotTimeline.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContainerId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedContainerName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedContainerIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
clickedContainerIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
nameMap: {
|
||||
type: Object,
|
||||
default: () => new Map(),
|
||||
},
|
||||
activeSubTab: {
|
||||
type: String,
|
||||
default: 'history',
|
||||
},
|
||||
loading: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
loaded: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
exporting: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
errors: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
historyRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
associationRows: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
workcenterGroups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedWorkcenterGroups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change-sub-tab', 'update-workcenter-groups', 'export-tab']);
|
||||
|
||||
const tabMeta = Object.freeze({
|
||||
history: { label: '歷程', emptyText: '無歷程資料' },
|
||||
materials: { label: '物料', emptyText: '無物料資料' },
|
||||
rejects: { label: '退貨', emptyText: '無退貨資料' },
|
||||
holds: { label: 'Hold', emptyText: '無 Hold 資料' },
|
||||
splits: { label: 'Split', emptyText: '無 Split 資料' },
|
||||
jobs: { label: 'Job', emptyText: '無 Job 資料' },
|
||||
});
|
||||
|
||||
const subTabs = Object.keys(tabMeta);
|
||||
|
||||
const activeRows = computed(() => {
|
||||
if (props.activeSubTab === 'history') {
|
||||
return props.historyRows;
|
||||
}
|
||||
return props.associationRows[props.activeSubTab] || [];
|
||||
});
|
||||
|
||||
const activeError = computed(() => {
|
||||
return props.errors[props.activeSubTab] || '';
|
||||
});
|
||||
|
||||
const activeLoading = computed(() => {
|
||||
return Boolean(props.loading[props.activeSubTab]);
|
||||
});
|
||||
|
||||
const activeLoaded = computed(() => {
|
||||
return Boolean(props.loaded[props.activeSubTab]);
|
||||
});
|
||||
|
||||
const activeExporting = computed(() => {
|
||||
return Boolean(props.exporting[props.activeSubTab]);
|
||||
});
|
||||
|
||||
const activeEmptyText = computed(() => {
|
||||
return tabMeta[props.activeSubTab]?.emptyText || '無資料';
|
||||
});
|
||||
|
||||
const canExport = computed(() => {
|
||||
return !activeLoading.value && activeRows.value.length > 0;
|
||||
});
|
||||
|
||||
const detailDisplayNames = computed(() => {
|
||||
const clicked = props.clickedContainerIds;
|
||||
if (clicked.length === 0) {
|
||||
return props.selectedContainerName || props.selectedContainerId;
|
||||
}
|
||||
return clicked
|
||||
.map((cid) => props.nameMap?.get?.(cid) || cid)
|
||||
.join('、');
|
||||
});
|
||||
|
||||
const subtreeCount = computed(() => {
|
||||
const total = props.selectedContainerIds.length;
|
||||
const clicked = props.clickedContainerIds.length || 1;
|
||||
return total > clicked ? total - clicked : 0;
|
||||
});
|
||||
|
||||
const detailCountLabel = computed(() => {
|
||||
const extra = subtreeCount.value;
|
||||
if (extra > 0) {
|
||||
return `(含 ${extra} 個子批次)`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
|
||||
<div v-if="!selectedContainerId" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-8 text-center text-xs text-slate-500">
|
||||
請從上方血緣樹選擇節點後查看明細。
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-800">LOT 明細{{ detailCountLabel }}</h3>
|
||||
<p class="text-xs text-slate-500">{{ detailDisplayNames }}</p>
|
||||
</div>
|
||||
|
||||
<ExportButton
|
||||
:disabled="!canExport"
|
||||
:loading="activeExporting"
|
||||
:label="`${tabMeta[activeSubTab]?.label || ''} 匯出 CSV`"
|
||||
@click="emit('export-tab', activeSubTab)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-wrap gap-2 border-b border-stroke-soft pb-2">
|
||||
<button
|
||||
v-for="tab in subTabs"
|
||||
:key="tab"
|
||||
type="button"
|
||||
class="rounded-card border px-3 py-1.5 text-xs font-medium transition"
|
||||
:class="tab === activeSubTab
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700'
|
||||
: 'border-transparent bg-surface-muted/70 text-slate-600 hover:border-stroke-soft hover:text-slate-800'"
|
||||
@click="emit('change-sub-tab', tab)"
|
||||
>
|
||||
{{ tabMeta[tab].label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="activeError" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
|
||||
{{ activeError }}
|
||||
</p>
|
||||
|
||||
<div v-if="activeSubTab === 'history'" class="space-y-3">
|
||||
<LotTimeline
|
||||
:history-rows="historyRows"
|
||||
:hold-rows="associationRows.holds || []"
|
||||
:material-rows="associationRows.materials || []"
|
||||
/>
|
||||
|
||||
<LotHistoryTable
|
||||
:rows="historyRows"
|
||||
:loading="loading.history"
|
||||
:workcenter-groups="workcenterGroups"
|
||||
:selected-workcenter-groups="selectedWorkcenterGroups"
|
||||
@update:workcenter-groups="emit('update-workcenter-groups', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LotAssociationTable
|
||||
v-else
|
||||
:rows="activeRows"
|
||||
:loading="activeLoading"
|
||||
:empty-text="activeLoaded ? activeEmptyText : '尚未查詢此分頁資料'"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
99
frontend/src/query-tool/components/LotHistoryTable.vue
Normal file
99
frontend/src/query-tool/components/LotHistoryTable.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import MultiSelect from '../../resource-shared/components/MultiSelect.vue';
|
||||
import { formatCellValue } from '../utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
workcenterGroups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedWorkcenterGroups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:workcenterGroups']);
|
||||
|
||||
const columns = computed(() => Object.keys(props.rows[0] || {}));
|
||||
|
||||
const workcenterOptions = computed(() => {
|
||||
return props.workcenterGroups.map((group) => {
|
||||
const name = typeof group === 'string' ? group : group?.name || group?.WORKCENTER_GROUP || '';
|
||||
return {
|
||||
value: String(name),
|
||||
label: String(name),
|
||||
};
|
||||
}).filter((option) => option.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div class="mb-2 flex flex-wrap items-end justify-between gap-2">
|
||||
<h4 class="text-sm font-semibold text-slate-800">歷程資料</h4>
|
||||
|
||||
<label class="min-w-[260px] text-xs text-slate-500">
|
||||
<span class="mb-1 block font-medium">站點群組篩選</span>
|
||||
<MultiSelect
|
||||
:model-value="selectedWorkcenterGroups"
|
||||
:options="workcenterOptions"
|
||||
placeholder="全部群組"
|
||||
searchable
|
||||
:disabled="loading"
|
||||
@update:model-value="emit('update:workcenterGroups', $event)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
歷程資料讀取中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="rows.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">
|
||||
無歷程資料
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[360px] overflow-auto rounded-card border border-stroke-soft">
|
||||
<table class="min-w-full border-collapse text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column"
|
||||
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
|
||||
>
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, rowIndex) in rows"
|
||||
:key="row.HISTORYMAINLINEID || row.TRACKINTIMESTAMP || rowIndex"
|
||||
class="odd:bg-white even:bg-slate-50"
|
||||
>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="`${rowIndex}-${column}`"
|
||||
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
|
||||
>
|
||||
{{ formatCellValue(row[column]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
174
frontend/src/query-tool/components/LotTimeline.vue
Normal file
174
frontend/src/query-tool/components/LotTimeline.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import TimelineChart from '../../shared-ui/components/TimelineChart.vue';
|
||||
import { hashColor, normalizeText, parseDateTime } from '../utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
historyRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
holdRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
materialRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
function safeDate(value) {
|
||||
const parsed = parseDateTime(value);
|
||||
return parsed ? parsed : null;
|
||||
}
|
||||
|
||||
function fallbackTrackId() {
|
||||
const first = props.historyRows[0];
|
||||
return normalizeText(first?.WORKCENTERNAME) || 'UNKNOWN_TRACK';
|
||||
}
|
||||
|
||||
const tracks = computed(() => {
|
||||
const grouped = new Map();
|
||||
|
||||
props.historyRows.forEach((row, index) => {
|
||||
const workcenterName = normalizeText(row?.WORKCENTERNAME) || `WORKCENTER-${index + 1}`;
|
||||
const start = safeDate(row?.TRACKINTIMESTAMP);
|
||||
const end = safeDate(row?.TRACKOUTTIMESTAMP) || (start ? new Date(start.getTime() + (1000 * 60 * 30)) : null);
|
||||
|
||||
if (!start || !end) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!grouped.has(workcenterName)) {
|
||||
grouped.set(workcenterName, []);
|
||||
}
|
||||
|
||||
grouped.get(workcenterName).push({
|
||||
id: `${workcenterName}-${index}`,
|
||||
start,
|
||||
end,
|
||||
type: workcenterName,
|
||||
label: row?.SPECNAME || workcenterName,
|
||||
detail: `${normalizeText(row?.CONTAINERNAME || row?.CONTAINERID)} | ${normalizeText(row?.EQUIPMENTNAME)}`,
|
||||
});
|
||||
});
|
||||
|
||||
return [...grouped.entries()].map(([trackId, bars]) => ({
|
||||
id: trackId,
|
||||
label: trackId,
|
||||
layers: [
|
||||
{
|
||||
id: `${trackId}-lots`,
|
||||
bars,
|
||||
opacity: 0.85,
|
||||
},
|
||||
],
|
||||
}));
|
||||
});
|
||||
|
||||
const events = computed(() => {
|
||||
const markers = [];
|
||||
|
||||
props.holdRows.forEach((row, index) => {
|
||||
const time = safeDate(row?.HOLDTXNDATE);
|
||||
if (!time) {
|
||||
return;
|
||||
}
|
||||
|
||||
markers.push({
|
||||
id: `hold-${index}`,
|
||||
trackId: normalizeText(row?.WORKCENTERNAME) || fallbackTrackId(),
|
||||
time,
|
||||
type: 'HOLD',
|
||||
shape: 'diamond',
|
||||
label: 'Hold',
|
||||
detail: `${normalizeText(row?.HOLDREASONNAME)} ${normalizeText(row?.HOLDCOMMENTS)}`.trim(),
|
||||
});
|
||||
});
|
||||
|
||||
props.materialRows.forEach((row, index) => {
|
||||
const time = safeDate(row?.TXNDATE);
|
||||
if (!time) {
|
||||
return;
|
||||
}
|
||||
|
||||
markers.push({
|
||||
id: `material-${index}`,
|
||||
trackId: normalizeText(row?.WORKCENTERNAME) || fallbackTrackId(),
|
||||
time,
|
||||
type: 'MATERIAL',
|
||||
shape: 'triangle',
|
||||
label: normalizeText(row?.MATERIALPARTNAME) || 'Material',
|
||||
detail: `Qty ${row?.QTYCONSUMED ?? '-'} / ${normalizeText(row?.MATERIALLOTNAME)}`,
|
||||
});
|
||||
});
|
||||
|
||||
return markers;
|
||||
});
|
||||
|
||||
const colorMap = computed(() => {
|
||||
const colors = {
|
||||
HOLD: '#f59e0b',
|
||||
MATERIAL: '#0ea5e9',
|
||||
};
|
||||
|
||||
tracks.value.forEach((track) => {
|
||||
colors[track.id] = hashColor(track.id);
|
||||
});
|
||||
|
||||
return colors;
|
||||
});
|
||||
|
||||
const timeRange = computed(() => {
|
||||
const timestamps = [];
|
||||
|
||||
tracks.value.forEach((track) => {
|
||||
(track.layers || []).forEach((layer) => {
|
||||
(layer.bars || []).forEach((bar) => {
|
||||
timestamps.push(bar.start?.getTime?.() || 0);
|
||||
timestamps.push(bar.end?.getTime?.() || 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
events.value.forEach((eventItem) => {
|
||||
timestamps.push(eventItem.time?.getTime?.() || 0);
|
||||
});
|
||||
|
||||
const normalized = timestamps.filter((item) => Number.isFinite(item) && item > 0);
|
||||
if (normalized.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
start: new Date(Math.min(...normalized)),
|
||||
end: new Date(Math.max(...normalized)),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-slate-800">LOT 生產 Timeline</h4>
|
||||
<p class="text-xs text-slate-500">Hold / Material 事件已覆蓋標記</p>
|
||||
</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"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
173
frontend/src/query-tool/components/LotTraceView.vue
Normal file
173
frontend/src/query-tool/components/LotTraceView.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<script setup>
|
||||
import QueryBar from './QueryBar.vue';
|
||||
import LineageTreeChart from './LineageTreeChart.vue';
|
||||
import LotDetail from './LotDetail.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inputType: {
|
||||
type: String,
|
||||
default: 'lot_id',
|
||||
},
|
||||
inputText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inputTypeOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
inputLimit: {
|
||||
type: Number,
|
||||
default: 50,
|
||||
},
|
||||
resolving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
resolveErrorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
resolveSuccessMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
treeRoots: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
notFound: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
lineageMap: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
nameMap: {
|
||||
type: Object,
|
||||
default: () => new Map(),
|
||||
},
|
||||
leafSerials: {
|
||||
type: Object,
|
||||
default: () => new Map(),
|
||||
},
|
||||
lineageLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedContainerIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedContainerId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedContainerName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
detailContainerIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
detailLoading: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
detailLoaded: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
detailExporting: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
detailErrors: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
activeSubTab: {
|
||||
type: String,
|
||||
default: 'history',
|
||||
},
|
||||
historyRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
associationRows: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
workcenterGroups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedWorkcenterGroups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:inputType',
|
||||
'update:inputText',
|
||||
'resolve',
|
||||
'select-nodes',
|
||||
'change-sub-tab',
|
||||
'update-workcenter-groups',
|
||||
'export-lot-tab',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<QueryBar
|
||||
:input-type="inputType"
|
||||
:input-text="inputText"
|
||||
:input-type-options="inputTypeOptions"
|
||||
:input-limit="inputLimit"
|
||||
:resolving="resolving"
|
||||
:error-message="resolveErrorMessage"
|
||||
@update:input-type="emit('update:inputType', $event)"
|
||||
@update:input-text="emit('update:inputText', $event)"
|
||||
@resolve="emit('resolve')"
|
||||
/>
|
||||
|
||||
<p v-if="resolveSuccessMessage" class="rounded-card border border-state-success/40 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
|
||||
{{ resolveSuccessMessage }}
|
||||
</p>
|
||||
|
||||
<LineageTreeChart
|
||||
:tree-roots="treeRoots"
|
||||
:not-found="notFound"
|
||||
:lineage-map="lineageMap"
|
||||
:name-map="nameMap"
|
||||
:leaf-serials="leafSerials"
|
||||
:selected-container-ids="selectedContainerIds"
|
||||
:loading="lineageLoading"
|
||||
@select-nodes="emit('select-nodes', $event)"
|
||||
/>
|
||||
|
||||
<LotDetail
|
||||
:selected-container-id="selectedContainerId"
|
||||
:selected-container-name="selectedContainerName"
|
||||
:selected-container-ids="detailContainerIds"
|
||||
:clicked-container-ids="selectedContainerIds"
|
||||
:name-map="nameMap"
|
||||
:active-sub-tab="activeSubTab"
|
||||
:loading="detailLoading"
|
||||
:loaded="detailLoaded"
|
||||
:exporting="detailExporting"
|
||||
:errors="detailErrors"
|
||||
:history-rows="historyRows"
|
||||
:association-rows="associationRows"
|
||||
:workcenter-groups="workcenterGroups"
|
||||
:selected-workcenter-groups="selectedWorkcenterGroups"
|
||||
@change-sub-tab="emit('change-sub-tab', $event)"
|
||||
@update-workcenter-groups="emit('update-workcenter-groups', $event)"
|
||||
@export-tab="emit('export-lot-tab', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
95
frontend/src/query-tool/components/QueryBar.vue
Normal file
95
frontend/src/query-tool/components/QueryBar.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import FilterToolbar from '../../shared-ui/components/FilterToolbar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inputType: {
|
||||
type: String,
|
||||
default: 'lot_id',
|
||||
},
|
||||
inputText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inputTypeOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
inputLimit: {
|
||||
type: Number,
|
||||
default: 50,
|
||||
},
|
||||
resolving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:inputType', 'update:inputText', 'resolve']);
|
||||
|
||||
const inputCount = computed(() => {
|
||||
return String(props.inputText || '')
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.length;
|
||||
});
|
||||
|
||||
function handleResolve() {
|
||||
emit('resolve');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
|
||||
<FilterToolbar>
|
||||
<label class="flex min-w-[220px] flex-col gap-1 text-xs text-slate-500">
|
||||
<span class="font-medium">查詢類型</span>
|
||||
<select
|
||||
class="h-9 rounded-card border border-stroke-soft bg-white px-3 text-sm text-slate-700 outline-none focus:border-brand-500"
|
||||
:value="inputType"
|
||||
:disabled="resolving"
|
||||
@change="emit('update:inputType', $event.target.value)"
|
||||
>
|
||||
<option
|
||||
v-for="option in inputTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-card bg-brand-500 px-4 text-sm font-medium text-white transition hover:bg-brand-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="resolving"
|
||||
@click="handleResolve"
|
||||
>
|
||||
{{ resolving ? '解析中...' : '解析' }}
|
||||
</button>
|
||||
</template>
|
||||
</FilterToolbar>
|
||||
|
||||
<div class="mt-3">
|
||||
<textarea
|
||||
:value="inputText"
|
||||
class="min-h-28 w-full rounded-card border border-stroke-soft bg-surface-muted/40 px-3 py-2 text-sm text-slate-700 outline-none transition focus:border-brand-500"
|
||||
:placeholder="`可輸入多筆(換行或逗號分隔),最多 ${inputLimit} 筆`"
|
||||
:disabled="resolving"
|
||||
@input="emit('update:inputText', $event.target.value)"
|
||||
/>
|
||||
<div class="mt-2 flex items-center justify-between text-xs">
|
||||
<p class="text-slate-500">已輸入 {{ inputCount }} / {{ inputLimit }}</p>
|
||||
<p v-if="errorMessage" class="text-state-danger">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
368
frontend/src/query-tool/composables/useEquipmentQuery.js
Normal file
368
frontend/src/query-tool/composables/useEquipmentQuery.js
Normal file
@@ -0,0 +1,368 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet, apiPost, ensureMesApiAvailable } from '../../core/api.js';
|
||||
import { exportCsv } from '../utils/csv.js';
|
||||
import { normalizeText, toDateInputValue, uniqueValues } from '../utils/values.js';
|
||||
|
||||
const EQUIPMENT_SUB_TABS = Object.freeze(['lots', 'jobs', 'rejects', 'timeline']);
|
||||
|
||||
function normalizeSubTab(value) {
|
||||
const tab = normalizeText(value).toLowerCase();
|
||||
return EQUIPMENT_SUB_TABS.includes(tab) ? tab : 'lots';
|
||||
}
|
||||
|
||||
function defaultDateRange(days = 30) {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - Number(days || 30));
|
||||
return {
|
||||
startDate: toDateInputValue(start),
|
||||
endDate: toDateInputValue(end),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyTabFlags() {
|
||||
return {
|
||||
lots: false,
|
||||
jobs: false,
|
||||
rejects: false,
|
||||
timeline: false,
|
||||
status_hours: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function useEquipmentQuery(initial = {}) {
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const equipmentOptions = ref([]);
|
||||
|
||||
const selectedEquipmentIds = ref(uniqueValues(initial.selectedEquipmentIds || []));
|
||||
const activeSubTab = ref(normalizeSubTab(initial.activeSubTab));
|
||||
|
||||
const rangeDefaults = defaultDateRange();
|
||||
const startDate = ref(normalizeText(initial.startDate) || rangeDefaults.startDate);
|
||||
const endDate = ref(normalizeText(initial.endDate) || rangeDefaults.endDate);
|
||||
|
||||
const lotsRows = ref([]);
|
||||
const jobsRows = ref([]);
|
||||
const rejectsRows = ref([]);
|
||||
const statusRows = ref([]);
|
||||
|
||||
const loading = reactive({
|
||||
bootstrapping: false,
|
||||
lots: false,
|
||||
jobs: false,
|
||||
rejects: false,
|
||||
timeline: false,
|
||||
status_hours: false,
|
||||
});
|
||||
|
||||
const errors = reactive({
|
||||
equipmentOptions: '',
|
||||
filters: '',
|
||||
lots: '',
|
||||
jobs: '',
|
||||
rejects: '',
|
||||
timeline: '',
|
||||
status_hours: '',
|
||||
});
|
||||
|
||||
const queried = reactive(emptyTabFlags());
|
||||
const exporting = reactive(emptyTabFlags());
|
||||
|
||||
const selectedEquipmentNames = computed(() => {
|
||||
const selectedSet = new Set(selectedEquipmentIds.value);
|
||||
return equipmentOptions.value
|
||||
.filter((item) => selectedSet.has(String(item.RESOURCEID)))
|
||||
.map((item) => item.RESOURCENAME)
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const equipmentOptionItems = computed(() => {
|
||||
return equipmentOptions.value.map((item) => ({
|
||||
value: String(item.RESOURCEID),
|
||||
label: item.RESOURCENAME ? `${item.RESOURCENAME} (${item.RESOURCEID})` : String(item.RESOURCEID),
|
||||
}));
|
||||
});
|
||||
|
||||
function resetDateRange(days = 30) {
|
||||
const defaults = defaultDateRange(days);
|
||||
startDate.value = defaults.startDate;
|
||||
endDate.value = defaults.endDate;
|
||||
}
|
||||
|
||||
function validateFilters() {
|
||||
if (selectedEquipmentIds.value.length === 0) {
|
||||
return '請選擇至少一台設備';
|
||||
}
|
||||
if (!startDate.value || !endDate.value) {
|
||||
return '請指定日期範圍';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildQueryPayload(queryType) {
|
||||
return {
|
||||
equipment_ids: selectedEquipmentIds.value,
|
||||
equipment_names: selectedEquipmentNames.value,
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
query_type: queryType,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchEquipmentPeriod(queryType) {
|
||||
const validation = validateFilters();
|
||||
if (validation) {
|
||||
throw new Error(validation);
|
||||
}
|
||||
|
||||
const payload = await apiPost(
|
||||
'/api/query-tool/equipment-period',
|
||||
buildQueryPayload(queryType),
|
||||
{ timeout: 120000, silent: true },
|
||||
);
|
||||
|
||||
return Array.isArray(payload?.data) ? payload.data : [];
|
||||
}
|
||||
|
||||
async function loadEquipmentOptions() {
|
||||
loading.bootstrapping = true;
|
||||
errors.equipmentOptions = '';
|
||||
|
||||
try {
|
||||
const payload = await apiGet('/api/query-tool/equipment-list', {
|
||||
timeout: 60000,
|
||||
silent: true,
|
||||
});
|
||||
equipmentOptions.value = Array.isArray(payload?.data) ? payload.data : [];
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors.equipmentOptions = error?.message || '載入設備清單失敗';
|
||||
equipmentOptions.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.bootstrapping = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function queryLots() {
|
||||
loading.lots = true;
|
||||
errors.filters = '';
|
||||
errors.lots = '';
|
||||
|
||||
try {
|
||||
lotsRows.value = await fetchEquipmentPeriod('lots');
|
||||
queried.lots = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors.lots = error?.message || '查詢生產紀錄失敗';
|
||||
if (!errors.filters) {
|
||||
errors.filters = errors.lots;
|
||||
}
|
||||
lotsRows.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.lots = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function queryJobs() {
|
||||
loading.jobs = true;
|
||||
errors.filters = '';
|
||||
errors.jobs = '';
|
||||
|
||||
try {
|
||||
jobsRows.value = await fetchEquipmentPeriod('jobs');
|
||||
queried.jobs = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors.jobs = error?.message || '查詢維修紀錄失敗';
|
||||
if (!errors.filters) {
|
||||
errors.filters = errors.jobs;
|
||||
}
|
||||
jobsRows.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.jobs = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function queryRejects() {
|
||||
loading.rejects = true;
|
||||
errors.filters = '';
|
||||
errors.rejects = '';
|
||||
|
||||
try {
|
||||
rejectsRows.value = await fetchEquipmentPeriod('rejects');
|
||||
queried.rejects = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors.rejects = error?.message || '查詢報廢紀錄失敗';
|
||||
if (!errors.filters) {
|
||||
errors.filters = errors.rejects;
|
||||
}
|
||||
rejectsRows.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.rejects = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function queryTimeline() {
|
||||
loading.timeline = true;
|
||||
loading.status_hours = true;
|
||||
errors.filters = '';
|
||||
errors.timeline = '';
|
||||
errors.status_hours = '';
|
||||
|
||||
try {
|
||||
const [statusData, lotsData, jobsData] = await Promise.all([
|
||||
fetchEquipmentPeriod('status_hours'),
|
||||
fetchEquipmentPeriod('lots'),
|
||||
fetchEquipmentPeriod('jobs'),
|
||||
]);
|
||||
|
||||
statusRows.value = statusData;
|
||||
lotsRows.value = lotsData;
|
||||
jobsRows.value = jobsData;
|
||||
|
||||
queried.timeline = true;
|
||||
queried.status_hours = true;
|
||||
queried.lots = true;
|
||||
queried.jobs = true;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error?.message || '查詢設備 Timeline 失敗';
|
||||
errors.timeline = message;
|
||||
errors.status_hours = message;
|
||||
if (!errors.filters) {
|
||||
errors.filters = message;
|
||||
}
|
||||
statusRows.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.timeline = false;
|
||||
loading.status_hours = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function queryActiveSubTab() {
|
||||
const tab = activeSubTab.value;
|
||||
if (tab === 'lots') {
|
||||
return queryLots();
|
||||
}
|
||||
if (tab === 'jobs') {
|
||||
return queryJobs();
|
||||
}
|
||||
if (tab === 'rejects') {
|
||||
return queryRejects();
|
||||
}
|
||||
return queryTimeline();
|
||||
}
|
||||
|
||||
async function setActiveSubTab(tab, { autoQuery = true } = {}) {
|
||||
activeSubTab.value = normalizeSubTab(tab);
|
||||
if (!autoQuery) {
|
||||
return true;
|
||||
}
|
||||
return queryActiveSubTab();
|
||||
}
|
||||
|
||||
function setSelectedEquipmentIds(ids = []) {
|
||||
selectedEquipmentIds.value = uniqueValues(ids);
|
||||
}
|
||||
|
||||
function canExportSubTab(tab) {
|
||||
const normalized = normalizeSubTab(tab);
|
||||
if (normalized === 'lots') {
|
||||
return lotsRows.value.length > 0;
|
||||
}
|
||||
if (normalized === 'jobs') {
|
||||
return jobsRows.value.length > 0;
|
||||
}
|
||||
if (normalized === 'rejects') {
|
||||
return rejectsRows.value.length > 0;
|
||||
}
|
||||
return statusRows.value.length > 0;
|
||||
}
|
||||
|
||||
async function exportSubTab(tab) {
|
||||
const normalized = normalizeSubTab(tab);
|
||||
|
||||
if (!canExportSubTab(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
exporting[normalized] = true;
|
||||
|
||||
try {
|
||||
let exportType = 'equipment_lots';
|
||||
const params = {
|
||||
equipment_ids: selectedEquipmentIds.value,
|
||||
equipment_names: selectedEquipmentNames.value,
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
};
|
||||
|
||||
if (normalized === 'jobs') {
|
||||
exportType = 'equipment_jobs';
|
||||
} else if (normalized === 'rejects') {
|
||||
exportType = 'equipment_rejects';
|
||||
} else if (normalized === 'timeline') {
|
||||
exportType = 'equipment_status_hours';
|
||||
}
|
||||
|
||||
await exportCsv({
|
||||
exportType,
|
||||
params,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error?.message || '匯出失敗';
|
||||
errors[normalized] = message;
|
||||
return false;
|
||||
} finally {
|
||||
exporting[normalized] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (!startDate.value || !endDate.value) {
|
||||
resetDateRange(30);
|
||||
}
|
||||
return loadEquipmentOptions();
|
||||
}
|
||||
|
||||
return {
|
||||
equipmentSubTabs: EQUIPMENT_SUB_TABS,
|
||||
equipmentOptions,
|
||||
equipmentOptionItems,
|
||||
selectedEquipmentIds,
|
||||
selectedEquipmentNames,
|
||||
startDate,
|
||||
endDate,
|
||||
activeSubTab,
|
||||
lotsRows,
|
||||
jobsRows,
|
||||
rejectsRows,
|
||||
statusRows,
|
||||
loading,
|
||||
errors,
|
||||
queried,
|
||||
exporting,
|
||||
bootstrap,
|
||||
resetDateRange,
|
||||
setSelectedEquipmentIds,
|
||||
setActiveSubTab,
|
||||
queryLots,
|
||||
queryJobs,
|
||||
queryRejects,
|
||||
queryTimeline,
|
||||
queryActiveSubTab,
|
||||
canExportSubTab,
|
||||
exportSubTab,
|
||||
};
|
||||
}
|
||||
511
frontend/src/query-tool/composables/useLotDetail.js
Normal file
511
frontend/src/query-tool/composables/useLotDetail.js
Normal file
@@ -0,0 +1,511 @@
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet, ensureMesApiAvailable } from '../../core/api.js';
|
||||
import { exportCsv } from '../utils/csv.js';
|
||||
import { normalizeText, parseDateTime, uniqueValues, formatDateTime } from '../utils/values.js';
|
||||
|
||||
const LOT_SUB_TABS = Object.freeze([
|
||||
'history',
|
||||
'materials',
|
||||
'rejects',
|
||||
'holds',
|
||||
'splits',
|
||||
'jobs',
|
||||
]);
|
||||
|
||||
const ASSOCIATION_TABS = new Set(['materials', 'rejects', 'holds', 'splits', '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',
|
||||
});
|
||||
|
||||
function emptyTabFlags() {
|
||||
return {
|
||||
history: false,
|
||||
materials: false,
|
||||
rejects: false,
|
||||
holds: false,
|
||||
splits: false,
|
||||
jobs: false,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyTabErrors() {
|
||||
return {
|
||||
workcenterGroups: '',
|
||||
history: '',
|
||||
materials: '',
|
||||
rejects: '',
|
||||
holds: '',
|
||||
splits: '',
|
||||
jobs: '',
|
||||
};
|
||||
}
|
||||
|
||||
function emptyAssociations() {
|
||||
return {
|
||||
materials: [],
|
||||
rejects: [],
|
||||
holds: [],
|
||||
splits: [],
|
||||
jobs: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSubTab(value) {
|
||||
const tab = normalizeText(value).toLowerCase();
|
||||
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;
|
||||
}
|
||||
|
||||
let minTrackIn = null;
|
||||
let maxTrackOut = null;
|
||||
|
||||
rows.forEach((row) => {
|
||||
const trackIn = parseDateTime(row?.TRACKINTIMESTAMP || row?.TRACKINTIME);
|
||||
const trackOut = parseDateTime(row?.TRACKOUTTIMESTAMP || row?.TRACKOUTTIME);
|
||||
|
||||
if (trackIn && (!minTrackIn || trackIn < minTrackIn)) {
|
||||
minTrackIn = trackIn;
|
||||
}
|
||||
|
||||
if (trackOut && (!maxTrackOut || trackOut > maxTrackOut)) {
|
||||
maxTrackOut = trackOut;
|
||||
}
|
||||
|
||||
if (!maxTrackOut && trackIn && (!maxTrackOut || trackIn > maxTrackOut)) {
|
||||
maxTrackOut = trackIn;
|
||||
}
|
||||
});
|
||||
|
||||
if (!minTrackIn || !maxTrackOut) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
time_start: formatDateTime(minTrackIn),
|
||||
time_end: formatDateTime(maxTrackOut),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEquipmentIdFromHistory(rows) {
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const equipmentId = normalizeText(row?.EQUIPMENTID || row?.RESOURCEID);
|
||||
if (equipmentId) {
|
||||
return equipmentId;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function useLotDetail(initial = {}) {
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const selectedContainerId = ref(normalizeText(initial.selectedContainerId));
|
||||
const selectedContainerIds = ref(
|
||||
initial.selectedContainerId ? [normalizeText(initial.selectedContainerId)] : [],
|
||||
);
|
||||
const activeSubTab = ref(normalizeSubTab(initial.activeSubTab));
|
||||
|
||||
const workcenterGroups = ref([]);
|
||||
const selectedWorkcenterGroups = ref(uniqueValues(initial.workcenterGroups || []));
|
||||
|
||||
const historyRows = ref([]);
|
||||
const associationRows = reactive(emptyAssociations());
|
||||
|
||||
const loading = reactive({
|
||||
workcenterGroups: false,
|
||||
history: false,
|
||||
materials: false,
|
||||
rejects: false,
|
||||
holds: false,
|
||||
splits: false,
|
||||
jobs: false,
|
||||
});
|
||||
|
||||
const loaded = reactive(emptyTabFlags());
|
||||
const exporting = reactive(emptyTabFlags());
|
||||
const errors = reactive(emptyTabErrors());
|
||||
|
||||
function clearTabData() {
|
||||
historyRows.value = [];
|
||||
const nextAssociations = emptyAssociations();
|
||||
Object.keys(nextAssociations).forEach((key) => {
|
||||
associationRows[key] = nextAssociations[key];
|
||||
});
|
||||
|
||||
const nextLoaded = emptyTabFlags();
|
||||
Object.keys(nextLoaded).forEach((key) => {
|
||||
loaded[key] = nextLoaded[key];
|
||||
exporting[key] = false;
|
||||
errors[key] = '';
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveCids() {
|
||||
if (selectedContainerIds.value.length > 0) {
|
||||
return selectedContainerIds.value;
|
||||
}
|
||||
const single = selectedContainerId.value;
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
async function loadWorkcenterGroups() {
|
||||
loading.workcenterGroups = true;
|
||||
errors.workcenterGroups = '';
|
||||
|
||||
try {
|
||||
const payload = await apiGet('/api/query-tool/workcenter-groups', {
|
||||
timeout: 60000,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
workcenterGroups.value = Array.isArray(payload?.data) ? payload.data : [];
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors.workcenterGroups = error?.message || '載入站點群組失敗';
|
||||
workcenterGroups.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.workcenterGroups = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory({ force = false } = {}) {
|
||||
const cids = getActiveCids();
|
||||
if (cids.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!force && loaded.history) {
|
||||
return true;
|
||||
}
|
||||
|
||||
loading.history = true;
|
||||
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(', ')}`;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors.history = error?.message || '載入 LOT 歷程失敗';
|
||||
historyRows.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.history = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssociation(tab, { force = false, silentError = false } = {}) {
|
||||
const associationType = normalizeSubTab(tab);
|
||||
if (!ASSOCIATION_TABS.has(associationType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cids = getActiveCids();
|
||||
if (cids.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!force && loaded[associationType]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
loading[associationType] = true;
|
||||
if (!silentError) {
|
||||
errors[associationType] = '';
|
||||
}
|
||||
|
||||
try {
|
||||
if (associationType === 'jobs') {
|
||||
// Jobs derive equipment/time from merged history — use first CID as anchor
|
||||
if (historyRows.value.length === 0) {
|
||||
await loadHistory();
|
||||
}
|
||||
|
||||
const equipmentId = resolveEquipmentIdFromHistory(historyRows.value);
|
||||
const timeRange = resolveTimeRangeFromHistory(historyRows.value);
|
||||
|
||||
if (!equipmentId || !timeRange?.time_start || !timeRange?.time_end) {
|
||||
throw new Error('無法從 LOT 歷程推導 JOB 查詢條件,請先確認歷程資料');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('container_id', cids[0]);
|
||||
params.set('type', associationType);
|
||||
params.set('equipment_id', equipmentId);
|
||||
params.set('time_start', timeRange.time_start);
|
||||
params.set('time_end', timeRange.time_end);
|
||||
|
||||
const payload = await apiGet(`/api/query-tool/lot-associations?${params.toString()}`, {
|
||||
timeout: 120000,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
associationRows[associationType] = allRows;
|
||||
}
|
||||
|
||||
loaded[associationType] = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
associationRows[associationType] = [];
|
||||
if (!silentError) {
|
||||
errors[associationType] = error?.message || '載入關聯資料失敗';
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
loading[associationType] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureActiveSubTabData() {
|
||||
if (activeSubTab.value === 'history') {
|
||||
const historyOk = await loadHistory();
|
||||
if (historyOk) {
|
||||
// History timeline uses hold/material events as marker sources.
|
||||
await Promise.allSettled([
|
||||
loadAssociation('holds', { silentError: true }),
|
||||
loadAssociation('materials', { silentError: true }),
|
||||
]);
|
||||
}
|
||||
return historyOk;
|
||||
}
|
||||
|
||||
return loadAssociation(activeSubTab.value);
|
||||
}
|
||||
|
||||
async function setActiveSubTab(tab) {
|
||||
activeSubTab.value = normalizeSubTab(tab);
|
||||
return ensureActiveSubTabData();
|
||||
}
|
||||
|
||||
async function setSelectedContainerId(containerId) {
|
||||
const nextId = normalizeText(containerId);
|
||||
selectedContainerIds.value = nextId ? [nextId] : [];
|
||||
|
||||
if (nextId === selectedContainerId.value) {
|
||||
return ensureActiveSubTabData();
|
||||
}
|
||||
|
||||
selectedContainerId.value = nextId;
|
||||
clearTabData();
|
||||
|
||||
if (!nextId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ensureActiveSubTabData();
|
||||
}
|
||||
|
||||
async function setSelectedContainerIds(cids) {
|
||||
const normalized = uniqueValues(
|
||||
(Array.isArray(cids) ? cids : []).map(normalizeText).filter(Boolean),
|
||||
);
|
||||
selectedContainerIds.value = normalized;
|
||||
selectedContainerId.value = normalized[0] || '';
|
||||
clearTabData();
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ensureActiveSubTabData();
|
||||
}
|
||||
|
||||
async function setSelectedWorkcenterGroups(groups) {
|
||||
selectedWorkcenterGroups.value = uniqueValues(groups || []);
|
||||
|
||||
if (!selectedContainerId.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
loaded.history = false;
|
||||
return loadHistory({ force: true });
|
||||
}
|
||||
|
||||
function getRowsByTab(tab) {
|
||||
const normalized = normalizeSubTab(tab);
|
||||
if (normalized === 'history') {
|
||||
return historyRows.value;
|
||||
}
|
||||
if (!ASSOCIATION_TABS.has(normalized)) {
|
||||
return [];
|
||||
}
|
||||
return associationRows[normalized] || [];
|
||||
}
|
||||
|
||||
async function exportSubTab(tab) {
|
||||
const normalized = normalizeSubTab(tab);
|
||||
const exportType = EXPORT_TYPE_MAP[normalized];
|
||||
const containerId = selectedContainerId.value;
|
||||
|
||||
if (!exportType || !containerId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
exporting[normalized] = true;
|
||||
errors[normalized] = '';
|
||||
|
||||
try {
|
||||
const params = {
|
||||
container_id: containerId,
|
||||
};
|
||||
|
||||
if (normalized === 'jobs') {
|
||||
if (historyRows.value.length === 0) {
|
||||
await loadHistory();
|
||||
}
|
||||
const equipmentId = resolveEquipmentIdFromHistory(historyRows.value);
|
||||
const timeRange = resolveTimeRangeFromHistory(historyRows.value);
|
||||
if (!equipmentId || !timeRange?.time_start || !timeRange?.time_end) {
|
||||
throw new Error('無法取得 JOB 匯出所需條件');
|
||||
}
|
||||
params.equipment_id = equipmentId;
|
||||
params.time_start = timeRange.time_start;
|
||||
params.time_end = timeRange.time_end;
|
||||
}
|
||||
|
||||
await exportCsv({
|
||||
exportType,
|
||||
params,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors[normalized] = error?.message || '匯出失敗';
|
||||
return false;
|
||||
} finally {
|
||||
exporting[normalized] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lotSubTabs: LOT_SUB_TABS,
|
||||
selectedContainerId,
|
||||
selectedContainerIds,
|
||||
activeSubTab,
|
||||
workcenterGroups,
|
||||
selectedWorkcenterGroups,
|
||||
historyRows,
|
||||
associationRows,
|
||||
loading,
|
||||
loaded,
|
||||
exporting,
|
||||
errors,
|
||||
loadWorkcenterGroups,
|
||||
loadHistory,
|
||||
loadAssociation,
|
||||
ensureActiveSubTabData,
|
||||
setActiveSubTab,
|
||||
setSelectedContainerId,
|
||||
setSelectedContainerIds,
|
||||
setSelectedWorkcenterGroups,
|
||||
getRowsByTab,
|
||||
exportSubTab,
|
||||
clearTabData,
|
||||
};
|
||||
}
|
||||
493
frontend/src/query-tool/composables/useLotLineage.js
Normal file
493
frontend/src/query-tool/composables/useLotLineage.js
Normal file
@@ -0,0 +1,493 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { apiPost, ensureMesApiAvailable } from '../../core/api.js';
|
||||
import { normalizeText, uniqueValues } from '../utils/values.js';
|
||||
|
||||
const MAX_CONCURRENCY = 3;
|
||||
const MAX_429_RETRY = 3;
|
||||
|
||||
function emptyLineageEntry() {
|
||||
return {
|
||||
children: [],
|
||||
loading: false,
|
||||
error: '',
|
||||
fetched: false,
|
||||
lastUpdatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function extractContainerId(row) {
|
||||
if (!row || typeof row !== 'object') {
|
||||
return '';
|
||||
}
|
||||
return normalizeText(row.container_id || row.CONTAINERID || row.containerId);
|
||||
}
|
||||
|
||||
function createSemaphore(maxConcurrency) {
|
||||
const queue = [];
|
||||
let active = 0;
|
||||
|
||||
function pump() {
|
||||
if (active >= maxConcurrency || queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = queue.shift();
|
||||
active += 1;
|
||||
|
||||
Promise.resolve()
|
||||
.then(item.task)
|
||||
.then(item.resolve)
|
||||
.catch(item.reject)
|
||||
.finally(() => {
|
||||
active = Math.max(0, active - 1);
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
schedule(task) {
|
||||
return new Promise((resolve, reject) => {
|
||||
queue.push({ task, resolve, reject });
|
||||
pump();
|
||||
});
|
||||
},
|
||||
clear() {
|
||||
queue.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function useLotLineage(initial = {}) {
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const lineageMap = reactive(new Map());
|
||||
const nameMap = reactive(new Map());
|
||||
const leafSerials = reactive(new Map());
|
||||
const expandedNodes = ref(new Set());
|
||||
const selectedContainerId = ref(normalizeText(initial.selectedContainerId));
|
||||
const selectedContainerIds = ref(
|
||||
initial.selectedContainerId ? [normalizeText(initial.selectedContainerId)] : [],
|
||||
);
|
||||
const rootRows = ref([]);
|
||||
const rootContainerIds = ref([]);
|
||||
const treeRoots = ref([]);
|
||||
|
||||
const inFlight = new Map();
|
||||
const semaphore = createSemaphore(MAX_CONCURRENCY);
|
||||
let generation = 0;
|
||||
|
||||
function ensureEntry(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!lineageMap.has(id)) {
|
||||
lineageMap.set(id, emptyLineageEntry());
|
||||
}
|
||||
|
||||
return lineageMap.get(id);
|
||||
}
|
||||
|
||||
function patchEntry(containerId, patch) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = ensureEntry(id) || emptyLineageEntry();
|
||||
lineageMap.set(id, {
|
||||
...previous,
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
|
||||
function getEntry(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return emptyLineageEntry();
|
||||
}
|
||||
return lineageMap.get(id) || emptyLineageEntry();
|
||||
}
|
||||
|
||||
function getChildren(containerId) {
|
||||
const entry = getEntry(containerId);
|
||||
return Array.isArray(entry.children) ? entry.children : [];
|
||||
}
|
||||
|
||||
function getSerials(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
return id ? (leafSerials.get(id) || []) : [];
|
||||
}
|
||||
|
||||
function getSubtreeCids(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return [];
|
||||
}
|
||||
const result = [];
|
||||
const visited = new Set();
|
||||
function walk(nodeId) {
|
||||
if (!nodeId || visited.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
visited.add(nodeId);
|
||||
result.push(nodeId);
|
||||
getChildren(nodeId).forEach(walk);
|
||||
}
|
||||
walk(id);
|
||||
return result;
|
||||
}
|
||||
|
||||
function isExpanded(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
return id ? expandedNodes.value.has(id) : false;
|
||||
}
|
||||
|
||||
function isSelected(containerId) {
|
||||
return normalizeText(containerId) === selectedContainerId.value;
|
||||
}
|
||||
|
||||
function selectNode(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
selectedContainerId.value = id;
|
||||
if (id && !selectedContainerIds.value.includes(id)) {
|
||||
selectedContainerIds.value = [id];
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectedNodes(cids) {
|
||||
const normalized = (Array.isArray(cids) ? cids : [])
|
||||
.map(normalizeText)
|
||||
.filter(Boolean);
|
||||
selectedContainerIds.value = uniqueValues(normalized);
|
||||
selectedContainerId.value = normalized[0] || '';
|
||||
}
|
||||
|
||||
const lineageLoading = computed(() => {
|
||||
for (const entry of lineageMap.values()) {
|
||||
if (entry.loading) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
function collapseAll() {
|
||||
expandedNodes.value = new Set();
|
||||
}
|
||||
|
||||
async function requestLineageWithRetry(containerIds) {
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt <= MAX_429_RETRY) {
|
||||
try {
|
||||
return await apiPost(
|
||||
'/api/trace/lineage',
|
||||
{
|
||||
profile: 'query_tool',
|
||||
container_ids: containerIds,
|
||||
},
|
||||
{ timeout: 60000, silent: true },
|
||||
);
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status !== 429 || attempt >= MAX_429_RETRY) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const retryAfter = Number(error?.retryAfterSeconds || 0);
|
||||
const fallbackSeconds = 2 ** attempt;
|
||||
const waitSeconds = Math.max(1, Math.min(30, retryAfter || fallbackSeconds));
|
||||
await sleep(waitSeconds * 1000);
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function populateForwardTree(payload) {
|
||||
const childrenMapData = payload?.children_map;
|
||||
const rootsList = payload?.roots || [];
|
||||
const serialsData = payload?.leaf_serials || {};
|
||||
const names = payload?.names;
|
||||
|
||||
// Merge name mapping
|
||||
if (names && typeof names === 'object') {
|
||||
Object.entries(names).forEach(([cid, name]) => {
|
||||
if (cid && name) {
|
||||
nameMap.set(normalizeText(cid), String(name));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Store leaf serial numbers
|
||||
Object.entries(serialsData).forEach(([cid, serials]) => {
|
||||
const id = normalizeText(cid);
|
||||
if (id && Array.isArray(serials)) {
|
||||
leafSerials.set(id, serials);
|
||||
}
|
||||
});
|
||||
|
||||
// Populate children_map for all nodes
|
||||
if (childrenMapData && typeof childrenMapData === 'object') {
|
||||
// First pass: set children for all parent nodes
|
||||
Object.entries(childrenMapData).forEach(([parentId, childIds]) => {
|
||||
const normalized = normalizeText(parentId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
patchEntry(normalized, {
|
||||
children: uniqueValues(childIds || []),
|
||||
loading: false,
|
||||
fetched: true,
|
||||
error: '',
|
||||
lastUpdatedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
// Second pass: mark leaf nodes (appear as children but not as parents)
|
||||
const allChildCids = new Set();
|
||||
Object.values(childrenMapData).forEach((childIds) => {
|
||||
(childIds || []).forEach((c) => {
|
||||
const nc = normalizeText(c);
|
||||
if (nc) {
|
||||
allChildCids.add(nc);
|
||||
}
|
||||
});
|
||||
});
|
||||
allChildCids.forEach((childCid) => {
|
||||
if (!childrenMapData[childCid] && !getEntry(childCid).fetched) {
|
||||
patchEntry(childCid, {
|
||||
children: [],
|
||||
loading: false,
|
||||
fetched: true,
|
||||
error: '',
|
||||
lastUpdatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Also mark roots as fetched
|
||||
rootsList.forEach((rootCid) => {
|
||||
const id = normalizeText(rootCid);
|
||||
if (id && !getEntry(id).fetched) {
|
||||
patchEntry(id, {
|
||||
children: uniqueValues(childrenMapData[rootCid] || []),
|
||||
loading: false,
|
||||
fetched: true,
|
||||
error: '',
|
||||
lastUpdatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update tree roots
|
||||
treeRoots.value = rootsList.map((r) => normalizeText(r)).filter(Boolean);
|
||||
}
|
||||
|
||||
async function fetchLineage(containerIds, { force = false } = {}) {
|
||||
const ids = (Array.isArray(containerIds) ? containerIds : [containerIds])
|
||||
.map((c) => normalizeText(c))
|
||||
.filter(Boolean);
|
||||
|
||||
if (ids.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if all already fetched (for single-node requests)
|
||||
if (!force && ids.length === 1) {
|
||||
const existing = getEntry(ids[0]);
|
||||
if (existing.fetched) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const cacheKey = [...ids].sort().join(',');
|
||||
if (inFlight.has(cacheKey)) {
|
||||
return inFlight.get(cacheKey);
|
||||
}
|
||||
|
||||
const runGeneration = generation;
|
||||
ids.forEach((id) => {
|
||||
patchEntry(id, { loading: true, error: '' });
|
||||
});
|
||||
|
||||
const promise = semaphore
|
||||
.schedule(async () => {
|
||||
try {
|
||||
const payload = await requestLineageWithRetry(ids);
|
||||
if (runGeneration !== generation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
populateForwardTree(payload);
|
||||
return ids.length === 1 ? getEntry(ids[0]) : null;
|
||||
} catch (error) {
|
||||
if (runGeneration !== generation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ids.forEach((id) => {
|
||||
patchEntry(id, {
|
||||
children: [],
|
||||
loading: false,
|
||||
fetched: true,
|
||||
error: error?.message || '血緣查詢失敗',
|
||||
lastUpdatedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
return ids.length === 1 ? getEntry(ids[0]) : null;
|
||||
} finally {
|
||||
inFlight.delete(cacheKey);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
inFlight.delete(cacheKey);
|
||||
throw error;
|
||||
});
|
||||
|
||||
inFlight.set(cacheKey, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
function expandNode(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Set(expandedNodes.value);
|
||||
next.add(id);
|
||||
expandedNodes.value = next;
|
||||
}
|
||||
|
||||
function collapseNode(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Set(expandedNodes.value);
|
||||
next.delete(id);
|
||||
expandedNodes.value = next;
|
||||
}
|
||||
|
||||
function toggleNode(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (expandedNodes.value.has(id)) {
|
||||
collapseNode(id);
|
||||
return;
|
||||
}
|
||||
|
||||
expandNode(id);
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
const expanded = new Set();
|
||||
|
||||
function walk(nodeId) {
|
||||
const id = normalizeText(nodeId);
|
||||
if (!id || expanded.has(id)) {
|
||||
return;
|
||||
}
|
||||
const entry = getEntry(id);
|
||||
if (entry.children && entry.children.length > 0) {
|
||||
expanded.add(id);
|
||||
entry.children.forEach((childId) => walk(childId));
|
||||
}
|
||||
}
|
||||
|
||||
treeRoots.value.forEach((rootId) => walk(rootId));
|
||||
expandedNodes.value = expanded;
|
||||
}
|
||||
|
||||
function resetLineageState() {
|
||||
generation += 1;
|
||||
semaphore.clear();
|
||||
inFlight.clear();
|
||||
lineageMap.clear();
|
||||
nameMap.clear();
|
||||
leafSerials.clear();
|
||||
expandedNodes.value = new Set();
|
||||
selectedContainerIds.value = [];
|
||||
treeRoots.value = [];
|
||||
}
|
||||
|
||||
async function primeResolvedLots(lots = []) {
|
||||
resetLineageState();
|
||||
|
||||
rootRows.value = Array.isArray(lots) ? [...lots] : [];
|
||||
rootContainerIds.value = rootRows.value
|
||||
.map((row) => extractContainerId(row))
|
||||
.filter(Boolean);
|
||||
|
||||
// Seed name map from resolve data
|
||||
rootRows.value.forEach((row) => {
|
||||
const cid = extractContainerId(row);
|
||||
const name = normalizeText(row?.lot_id || row?.CONTAINERNAME || row?.input_value);
|
||||
if (cid && name) {
|
||||
nameMap.set(cid, name);
|
||||
}
|
||||
});
|
||||
|
||||
if (rootContainerIds.value.length > 0 && !selectedContainerId.value) {
|
||||
selectedContainerId.value = rootContainerIds.value[0];
|
||||
}
|
||||
|
||||
rootContainerIds.value.forEach((containerId) => {
|
||||
patchEntry(containerId, { loading: true });
|
||||
});
|
||||
|
||||
// Send all seed CIDs in a single request for forward tree
|
||||
await fetchLineage(rootContainerIds.value);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedContainerId.value = '';
|
||||
selectedContainerIds.value = [];
|
||||
}
|
||||
|
||||
return {
|
||||
lineageMap,
|
||||
nameMap,
|
||||
leafSerials,
|
||||
expandedNodes,
|
||||
selectedContainerId,
|
||||
selectedContainerIds,
|
||||
lineageLoading,
|
||||
rootRows,
|
||||
rootContainerIds,
|
||||
treeRoots,
|
||||
getEntry,
|
||||
getChildren,
|
||||
getSerials,
|
||||
getSubtreeCids,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
selectNode,
|
||||
setSelectedNodes,
|
||||
fetchLineage,
|
||||
expandNode,
|
||||
collapseNode,
|
||||
toggleNode,
|
||||
expandAll,
|
||||
collapseAll,
|
||||
primeResolvedLots,
|
||||
resetLineageState,
|
||||
clearSelection,
|
||||
extractContainerId,
|
||||
};
|
||||
}
|
||||
152
frontend/src/query-tool/composables/useLotResolve.js
Normal file
152
frontend/src/query-tool/composables/useLotResolve.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { apiPost, ensureMesApiAvailable } from '../../core/api.js';
|
||||
import { parseInputValues } from '../utils/values.js';
|
||||
|
||||
const INPUT_TYPE_OPTIONS = Object.freeze([
|
||||
{ value: 'lot_id', label: 'LOT ID' },
|
||||
{ value: 'serial_number', label: '流水號' },
|
||||
{ value: 'work_order', label: '工單' },
|
||||
]);
|
||||
|
||||
const INPUT_LIMITS = Object.freeze({
|
||||
lot_id: 50,
|
||||
serial_number: 50,
|
||||
work_order: 10,
|
||||
});
|
||||
|
||||
function normalizeInputType(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (INPUT_LIMITS[text]) {
|
||||
return text;
|
||||
}
|
||||
return 'lot_id';
|
||||
}
|
||||
|
||||
export function useLotResolve(initial = {}) {
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const inputType = ref(normalizeInputType(initial.inputType));
|
||||
const inputText = ref(String(initial.inputText || ''));
|
||||
|
||||
const resolvedLots = ref([]);
|
||||
const notFound = ref([]);
|
||||
const expansionInfo = ref({});
|
||||
|
||||
const errorMessage = ref('');
|
||||
const successMessage = ref('');
|
||||
|
||||
const loading = reactive({
|
||||
resolving: false,
|
||||
});
|
||||
|
||||
const inputTypeOptions = INPUT_TYPE_OPTIONS;
|
||||
const inputValues = computed(() => parseInputValues(inputText.value));
|
||||
const inputLimit = computed(() => INPUT_LIMITS[inputType.value] || 50);
|
||||
|
||||
function clearMessages() {
|
||||
errorMessage.value = '';
|
||||
successMessage.value = '';
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
resolvedLots.value = [];
|
||||
notFound.value = [];
|
||||
expansionInfo.value = {};
|
||||
}
|
||||
|
||||
function reset() {
|
||||
inputText.value = '';
|
||||
clearMessages();
|
||||
clearResults();
|
||||
}
|
||||
|
||||
function setInputType(nextType) {
|
||||
inputType.value = normalizeInputType(nextType);
|
||||
}
|
||||
|
||||
function setInputText(text) {
|
||||
inputText.value = String(text || '');
|
||||
}
|
||||
|
||||
function validateInput(values) {
|
||||
if (values.length === 0) {
|
||||
return '請輸入 LOT/流水號/工單條件';
|
||||
}
|
||||
|
||||
const limit = INPUT_LIMITS[inputType.value] || 50;
|
||||
if (values.length > limit) {
|
||||
return `輸入數量超過上限 (${limit} 筆)`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function resolveLots() {
|
||||
const values = inputValues.value;
|
||||
const validationMessage = validateInput(values);
|
||||
|
||||
if (validationMessage) {
|
||||
errorMessage.value = validationMessage;
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'validation',
|
||||
};
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
clearResults();
|
||||
loading.resolving = true;
|
||||
|
||||
try {
|
||||
const payload = await apiPost(
|
||||
'/api/query-tool/resolve',
|
||||
{
|
||||
input_type: inputType.value,
|
||||
values,
|
||||
},
|
||||
{ timeout: 60000, silent: true },
|
||||
);
|
||||
|
||||
resolvedLots.value = Array.isArray(payload?.data) ? payload.data : [];
|
||||
notFound.value = Array.isArray(payload?.not_found) ? payload.not_found : [];
|
||||
expansionInfo.value = payload?.expansion_info || {};
|
||||
|
||||
successMessage.value = `解析完成:${resolvedLots.value.length} 筆,未命中 ${notFound.value.length} 筆`;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
resolvedLots: resolvedLots.value,
|
||||
notFound: notFound.value,
|
||||
};
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '解析失敗';
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'request',
|
||||
};
|
||||
} finally {
|
||||
loading.resolving = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
inputType,
|
||||
inputText,
|
||||
inputTypeOptions,
|
||||
inputValues,
|
||||
inputLimit,
|
||||
resolvedLots,
|
||||
notFound,
|
||||
expansionInfo,
|
||||
loading,
|
||||
errorMessage,
|
||||
successMessage,
|
||||
clearMessages,
|
||||
clearResults,
|
||||
reset,
|
||||
setInputType,
|
||||
setInputText,
|
||||
resolveLots,
|
||||
};
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet, apiPost, ensureMesApiAvailable } from '../../core/api.js';
|
||||
import { replaceRuntimeHistory } from '../../core/shell-navigation.js';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
|
||||
function toDateString(date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function parseArrayQuery(params, key) {
|
||||
const repeated = params.getAll(key).map((item) => String(item || '').trim()).filter(Boolean);
|
||||
if (repeated.length > 0) {
|
||||
return repeated;
|
||||
}
|
||||
return String(params.get(key) || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildBatchQueryString(state) {
|
||||
const params = new URLSearchParams();
|
||||
if (state.inputType) params.set('input_type', state.inputType);
|
||||
if (state.selectedContainerId) params.set('container_id', state.selectedContainerId);
|
||||
if (state.associationType) params.set('association_type', state.associationType);
|
||||
state.selectedWorkcenterGroups.forEach((item) => params.append('workcenter_groups', item));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function buildEquipmentQueryString(state) {
|
||||
const params = new URLSearchParams();
|
||||
state.selectedEquipmentIds.forEach((item) => params.append('equipment_ids', item));
|
||||
if (state.startDate) params.set('start_date', state.startDate);
|
||||
if (state.endDate) params.set('end_date', state.endDate);
|
||||
if (state.equipmentQueryType) params.set('query_type', state.equipmentQueryType);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function mapEquipmentExportType(queryType) {
|
||||
const normalized = String(queryType || '').trim();
|
||||
const mapping = {
|
||||
status_hours: 'equipment_status_hours',
|
||||
lots: 'equipment_lots',
|
||||
materials: 'equipment_materials',
|
||||
rejects: 'equipment_rejects',
|
||||
jobs: 'equipment_jobs',
|
||||
};
|
||||
return mapping[normalized] || null;
|
||||
}
|
||||
|
||||
function mapAssociationExportType(associationType) {
|
||||
const normalized = String(associationType || '').trim();
|
||||
const mapping = {
|
||||
materials: 'lot_materials',
|
||||
rejects: 'lot_rejects',
|
||||
holds: 'lot_holds',
|
||||
splits: 'lot_splits',
|
||||
jobs: 'lot_jobs',
|
||||
};
|
||||
return mapping[normalized] || null;
|
||||
}
|
||||
|
||||
export function useQueryToolData() {
|
||||
const loading = reactive({
|
||||
resolving: false,
|
||||
history: false,
|
||||
association: false,
|
||||
equipment: false,
|
||||
exporting: false,
|
||||
bootstrapping: false,
|
||||
});
|
||||
|
||||
const errorMessage = ref('');
|
||||
const successMessage = ref('');
|
||||
|
||||
const batch = reactive({
|
||||
inputType: 'lot_id',
|
||||
inputText: '',
|
||||
resolvedLots: [],
|
||||
selectedContainerId: '',
|
||||
selectedWorkcenterGroups: [],
|
||||
workcenterGroups: [],
|
||||
lotHistoryRows: [],
|
||||
associationType: 'materials',
|
||||
associationRows: [],
|
||||
lineageCache: {},
|
||||
});
|
||||
|
||||
const equipment = reactive({
|
||||
options: [],
|
||||
selectedEquipmentIds: [],
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
equipmentQueryType: 'status_hours',
|
||||
rows: [],
|
||||
});
|
||||
|
||||
const resolvedColumns = computed(() => Object.keys(batch.resolvedLots[0] || {}));
|
||||
const historyColumns = computed(() => Object.keys(batch.lotHistoryRows[0] || {}));
|
||||
const associationColumns = computed(() => Object.keys(batch.associationRows[0] || {}));
|
||||
const equipmentColumns = computed(() => Object.keys(equipment.rows[0] || {}));
|
||||
|
||||
const selectedEquipmentNames = computed(() => {
|
||||
const selectedSet = new Set(equipment.selectedEquipmentIds);
|
||||
return equipment.options
|
||||
.filter((item) => selectedSet.has(item.RESOURCEID))
|
||||
.map((item) => item.RESOURCENAME)
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
function hydrateFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
batch.inputType = String(params.get('input_type') || 'lot_id').trim() || 'lot_id';
|
||||
batch.selectedContainerId = String(params.get('container_id') || '').trim();
|
||||
batch.associationType = String(params.get('association_type') || 'materials').trim() || 'materials';
|
||||
batch.selectedWorkcenterGroups = parseArrayQuery(params, 'workcenter_groups');
|
||||
|
||||
equipment.selectedEquipmentIds = parseArrayQuery(params, 'equipment_ids');
|
||||
equipment.startDate = String(params.get('start_date') || '').trim();
|
||||
equipment.endDate = String(params.get('end_date') || '').trim();
|
||||
equipment.equipmentQueryType = String(params.get('query_type') || 'status_hours').trim() || 'status_hours';
|
||||
}
|
||||
|
||||
function resetEquipmentDateRange() {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - 30);
|
||||
equipment.startDate = toDateString(start);
|
||||
equipment.endDate = toDateString(end);
|
||||
}
|
||||
|
||||
function syncBatchUrlState() {
|
||||
const query = buildBatchQueryString(batch);
|
||||
replaceRuntimeHistory(query ? `/query-tool?${query}` : '/query-tool');
|
||||
}
|
||||
|
||||
function syncEquipmentUrlState() {
|
||||
const query = buildEquipmentQueryString(equipment);
|
||||
replaceRuntimeHistory(query ? `/query-tool?${query}` : '/query-tool');
|
||||
}
|
||||
|
||||
function parseBatchInputValues() {
|
||||
return String(batch.inputText || '')
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function loadEquipmentOptions() {
|
||||
try {
|
||||
const payload = await apiGet('/api/query-tool/equipment-list', { timeout: 60000, silent: true });
|
||||
equipment.options = Array.isArray(payload?.data) ? payload.data : [];
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '載入設備選單失敗';
|
||||
equipment.options = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkcenterGroups() {
|
||||
try {
|
||||
const payload = await apiGet('/api/query-tool/workcenter-groups', { timeout: 60000, silent: true });
|
||||
batch.workcenterGroups = Array.isArray(payload?.data) ? payload.data : [];
|
||||
} catch {
|
||||
batch.workcenterGroups = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
loading.bootstrapping = true;
|
||||
errorMessage.value = '';
|
||||
await Promise.all([loadEquipmentOptions(), loadWorkcenterGroups()]);
|
||||
loading.bootstrapping = false;
|
||||
}
|
||||
|
||||
async function resolveLots() {
|
||||
const values = parseBatchInputValues();
|
||||
if (values.length === 0) {
|
||||
errorMessage.value = '請輸入 LOT/流水號/工單條件';
|
||||
return false;
|
||||
}
|
||||
loading.resolving = true;
|
||||
errorMessage.value = '';
|
||||
successMessage.value = '';
|
||||
batch.selectedContainerId = '';
|
||||
batch.lotHistoryRows = [];
|
||||
batch.associationRows = [];
|
||||
batch.lineageCache = {};
|
||||
syncBatchUrlState();
|
||||
try {
|
||||
const payload = await apiPost(
|
||||
'/api/query-tool/resolve',
|
||||
{
|
||||
input_type: batch.inputType,
|
||||
values,
|
||||
},
|
||||
{ timeout: 60000, silent: true },
|
||||
);
|
||||
batch.resolvedLots = Array.isArray(payload?.data) ? payload.data : [];
|
||||
const notFound = Array.isArray(payload?.not_found) ? payload.not_found : [];
|
||||
successMessage.value = `解析完成:${batch.resolvedLots.length} 筆,未命中 ${notFound.length} 筆`;
|
||||
return true;
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '解析失敗';
|
||||
batch.resolvedLots = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.resolving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLotHistory(containerId) {
|
||||
const id = String(containerId || '').trim();
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
loading.history = true;
|
||||
errorMessage.value = '';
|
||||
batch.selectedContainerId = id;
|
||||
syncBatchUrlState();
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('container_id', id);
|
||||
batch.selectedWorkcenterGroups.forEach((item) => params.append('workcenter_groups', item));
|
||||
const payload = await apiGet(`/api/query-tool/lot-history?${params.toString()}`, {
|
||||
timeout: 60000,
|
||||
silent: true,
|
||||
});
|
||||
batch.lotHistoryRows = Array.isArray(payload?.data) ? payload.data : [];
|
||||
return true;
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '查詢 LOT 歷程失敗';
|
||||
batch.lotHistoryRows = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.history = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLotLineage(containerId) {
|
||||
const id = String(containerId || '').trim();
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cached = batch.lineageCache[id];
|
||||
if (cached?.loading || cached?.ancestors) {
|
||||
return true;
|
||||
}
|
||||
|
||||
batch.lineageCache[id] = {
|
||||
ancestors: null,
|
||||
merges: null,
|
||||
loading: true,
|
||||
error: '',
|
||||
};
|
||||
|
||||
try {
|
||||
const payload = await apiPost(
|
||||
'/api/trace/lineage',
|
||||
{
|
||||
profile: 'query_tool',
|
||||
container_ids: [id],
|
||||
},
|
||||
{ timeout: 60000, silent: true },
|
||||
);
|
||||
|
||||
batch.lineageCache[id] = {
|
||||
ancestors: payload?.ancestors || {},
|
||||
merges: payload?.merges || {},
|
||||
loading: false,
|
||||
error: '',
|
||||
};
|
||||
return true;
|
||||
} catch (error) {
|
||||
batch.lineageCache[id] = {
|
||||
ancestors: null,
|
||||
merges: null,
|
||||
loading: false,
|
||||
error: error?.message || '血緣查詢失敗',
|
||||
};
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssociations() {
|
||||
if (!batch.selectedContainerId) {
|
||||
errorMessage.value = '請先選擇一筆 CONTAINERID';
|
||||
return false;
|
||||
}
|
||||
loading.association = true;
|
||||
errorMessage.value = '';
|
||||
syncBatchUrlState();
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
container_id: batch.selectedContainerId,
|
||||
type: batch.associationType,
|
||||
});
|
||||
const payload = await apiGet(`/api/query-tool/lot-associations?${params.toString()}`, {
|
||||
timeout: 60000,
|
||||
silent: true,
|
||||
});
|
||||
batch.associationRows = Array.isArray(payload?.data) ? payload.data : [];
|
||||
return true;
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '查詢關聯資料失敗';
|
||||
batch.associationRows = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.association = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function queryEquipmentPeriod() {
|
||||
if (equipment.selectedEquipmentIds.length === 0) {
|
||||
errorMessage.value = '請選擇至少一台設備';
|
||||
return false;
|
||||
}
|
||||
if (!equipment.startDate || !equipment.endDate) {
|
||||
errorMessage.value = '請指定設備查詢日期範圍';
|
||||
return false;
|
||||
}
|
||||
loading.equipment = true;
|
||||
errorMessage.value = '';
|
||||
successMessage.value = '';
|
||||
syncEquipmentUrlState();
|
||||
try {
|
||||
const payload = await apiPost(
|
||||
'/api/query-tool/equipment-period',
|
||||
{
|
||||
equipment_ids: equipment.selectedEquipmentIds,
|
||||
equipment_names: selectedEquipmentNames.value,
|
||||
start_date: equipment.startDate,
|
||||
end_date: equipment.endDate,
|
||||
query_type: equipment.equipmentQueryType,
|
||||
},
|
||||
{ timeout: 120000, silent: true },
|
||||
);
|
||||
equipment.rows = Array.isArray(payload?.data) ? payload.data : [];
|
||||
successMessage.value = `設備查詢完成:${equipment.rows.length} 筆`;
|
||||
return true;
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '設備查詢失敗';
|
||||
equipment.rows = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.equipment = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCurrentCsv() {
|
||||
loading.exporting = true;
|
||||
errorMessage.value = '';
|
||||
successMessage.value = '';
|
||||
|
||||
let exportType = null;
|
||||
let params = {};
|
||||
|
||||
if (equipment.rows.length > 0) {
|
||||
exportType = mapEquipmentExportType(equipment.equipmentQueryType);
|
||||
params = {
|
||||
equipment_ids: equipment.selectedEquipmentIds,
|
||||
equipment_names: selectedEquipmentNames.value,
|
||||
start_date: equipment.startDate,
|
||||
end_date: equipment.endDate,
|
||||
};
|
||||
} else if (batch.selectedContainerId && batch.associationRows.length > 0) {
|
||||
exportType = mapAssociationExportType(batch.associationType);
|
||||
params = {
|
||||
container_id: batch.selectedContainerId,
|
||||
};
|
||||
} else if (batch.selectedContainerId && batch.lotHistoryRows.length > 0) {
|
||||
exportType = 'lot_history';
|
||||
params = {
|
||||
container_id: batch.selectedContainerId,
|
||||
};
|
||||
}
|
||||
|
||||
if (!exportType) {
|
||||
loading.exporting = false;
|
||||
errorMessage.value = '無可匯出的查詢結果';
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/query-tool/export-csv', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
export_type: exportType,
|
||||
params,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `匯出失敗 (${response.status})`;
|
||||
try {
|
||||
const payload = await response.json();
|
||||
message = payload?.error || payload?.message || message;
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const href = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = href;
|
||||
anchor.download = `${exportType}.csv`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.URL.revokeObjectURL(href);
|
||||
successMessage.value = `CSV 匯出成功:${exportType}`;
|
||||
return true;
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '匯出失敗';
|
||||
return false;
|
||||
} finally {
|
||||
loading.exporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
errorMessage,
|
||||
successMessage,
|
||||
batch,
|
||||
equipment,
|
||||
resolvedColumns,
|
||||
historyColumns,
|
||||
associationColumns,
|
||||
equipmentColumns,
|
||||
hydrateFromUrl,
|
||||
resetEquipmentDateRange,
|
||||
bootstrap,
|
||||
resolveLots,
|
||||
loadLotLineage,
|
||||
loadLotHistory,
|
||||
loadAssociations,
|
||||
queryEquipmentPeriod,
|
||||
exportCurrentCsv,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,180 +0,0 @@
|
||||
.query-tool-page {
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.query-tool-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);
|
||||
}
|
||||
|
||||
.query-tool-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.query-tool-header p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.query-tool-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.query-tool-filter select,
|
||||
.query-tool-filter input {
|
||||
min-width: 160px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.query-tool-textarea {
|
||||
width: 100%;
|
||||
min-height: 110px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.query-tool-btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.query-tool-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.query-tool-btn-primary {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.query-tool-btn-success {
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.query-tool-btn-ghost {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.query-tool-row-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.query-tool-table-wrap {
|
||||
margin-top: 12px;
|
||||
overflow: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.query-tool-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.query-tool-table th,
|
||||
.query-tool-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.query-tool-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f8fafc;
|
||||
text-align: left;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.query-tool-table tr.selected {
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.query-tool-lineage-row td {
|
||||
background: #f8fafc;
|
||||
border-top: 1px dashed #dbeafe;
|
||||
}
|
||||
|
||||
.query-tool-lineage-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.query-tool-lineage-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.query-tool-error-inline {
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.query-tool-empty {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.query-tool-error {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.query-tool-success {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.query-tool-page {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
76
frontend/src/query-tool/utils/csv.js
Normal file
76
frontend/src/query-tool/utils/csv.js
Normal file
@@ -0,0 +1,76 @@
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
function resolveErrorMessage(status, payload) {
|
||||
if (payload?.error?.message) {
|
||||
return String(payload.error.message);
|
||||
}
|
||||
if (typeof payload?.error === 'string') {
|
||||
return payload.error;
|
||||
}
|
||||
if (typeof payload?.message === 'string' && payload.message) {
|
||||
return payload.message;
|
||||
}
|
||||
return `匯出失敗 (${status})`;
|
||||
}
|
||||
|
||||
function resolveDownloadFilename(response, fallbackName) {
|
||||
const disposition = response.headers.get('Content-Disposition') || '';
|
||||
const match = disposition.match(/filename=([^;]+)/i);
|
||||
if (!match?.[1]) {
|
||||
return fallbackName;
|
||||
}
|
||||
return match[1].replace(/(^['\"]|['\"]$)/g, '').trim() || fallbackName;
|
||||
}
|
||||
|
||||
export async function exportCsv({ exportType, params = {}, fallbackFilename = null }) {
|
||||
if (!exportType) {
|
||||
throw new Error('缺少匯出類型');
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const csrfToken = getCsrfToken();
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/query-tool/export-csv', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
export_type: exportType,
|
||||
params,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
throw new Error(resolveErrorMessage(response.status, payload));
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const href = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const filename = resolveDownloadFilename(
|
||||
response,
|
||||
fallbackFilename || `${exportType}.csv`,
|
||||
);
|
||||
|
||||
link.href = href;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(href);
|
||||
|
||||
return filename;
|
||||
}
|
||||
93
frontend/src/query-tool/utils/values.js
Normal file
93
frontend/src/query-tool/utils/values.js
Normal file
@@ -0,0 +1,93 @@
|
||||
export function normalizeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
export function uniqueValues(values = []) {
|
||||
const seen = new Set();
|
||||
const list = [];
|
||||
values.forEach((value) => {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
list.push(normalized);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
export function parseInputValues(raw) {
|
||||
return uniqueValues(String(raw || '').split(/[\n,]/));
|
||||
}
|
||||
|
||||
export function parseArrayParam(params, key) {
|
||||
const repeated = params.getAll(key).map((item) => normalizeText(item)).filter(Boolean);
|
||||
if (repeated.length > 0) {
|
||||
return uniqueValues(repeated);
|
||||
}
|
||||
const fallback = normalizeText(params.get(key));
|
||||
if (!fallback) {
|
||||
return [];
|
||||
}
|
||||
return uniqueValues(fallback.split(','));
|
||||
}
|
||||
|
||||
export function toDateInputValue(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function parseDateTime(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value;
|
||||
}
|
||||
const date = new Date(String(value).replace(' ', 'T'));
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
export function formatDateTime(value) {
|
||||
const date = parseDateTime(value);
|
||||
if (!date) {
|
||||
return '-';
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hour = String(date.getHours()).padStart(2, '0');
|
||||
const minute = String(date.getMinutes()).padStart(2, '0');
|
||||
const second = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
}
|
||||
|
||||
export function formatCellValue(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '-';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value.toLocaleString() : '-';
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function hashColor(seed) {
|
||||
const text = normalizeText(seed) || 'fallback';
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
hash = (hash << 5) - hash + text.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
const hue = Math.abs(hash) % 360;
|
||||
return `hsl(${hue} 70% 52%)`;
|
||||
}
|
||||
443
frontend/src/shared-ui/components/TimelineChart.vue
Normal file
443
frontend/src/shared-ui/components/TimelineChart.vue
Normal file
@@ -0,0 +1,443 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { formatDateTime, normalizeText, parseDateTime } from '../../query-tool/utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
tracks: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
events: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
timeRange: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
colorMap: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
labelWidth: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
},
|
||||
trackRowHeight: {
|
||||
type: Number,
|
||||
default: 44,
|
||||
},
|
||||
minChartWidth: {
|
||||
type: Number,
|
||||
default: 960,
|
||||
},
|
||||
});
|
||||
|
||||
const AXIS_HEIGHT = 42;
|
||||
const tooltipRef = ref({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
title: '',
|
||||
lines: [],
|
||||
});
|
||||
const containerRef = ref(null);
|
||||
|
||||
function toTimestamp(value) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
const date = parseDateTime(value);
|
||||
return date ? date.getTime() : null;
|
||||
}
|
||||
|
||||
function collectDomainTimestamps() {
|
||||
const timestamps = [];
|
||||
|
||||
props.tracks.forEach((track) => {
|
||||
const layers = Array.isArray(track?.layers) ? track.layers : [];
|
||||
layers.forEach((layer) => {
|
||||
const bars = Array.isArray(layer?.bars) ? layer.bars : [];
|
||||
bars.forEach((bar) => {
|
||||
const startMs = toTimestamp(bar?.start);
|
||||
const endMs = toTimestamp(bar?.end);
|
||||
if (startMs !== null) timestamps.push(startMs);
|
||||
if (endMs !== null) timestamps.push(endMs);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
props.events.forEach((event) => {
|
||||
const timeMs = toTimestamp(event?.time);
|
||||
if (timeMs !== null) timestamps.push(timeMs);
|
||||
});
|
||||
|
||||
return timestamps;
|
||||
}
|
||||
|
||||
const normalizedTimeRange = computed(() => {
|
||||
const explicitStart = toTimestamp(props.timeRange?.start);
|
||||
const explicitEnd = toTimestamp(props.timeRange?.end);
|
||||
|
||||
if (explicitStart !== null && explicitEnd !== null && explicitEnd > explicitStart) {
|
||||
return {
|
||||
startMs: explicitStart,
|
||||
endMs: explicitEnd,
|
||||
};
|
||||
}
|
||||
|
||||
const timestamps = collectDomainTimestamps();
|
||||
if (timestamps.length === 0) {
|
||||
const now = Date.now();
|
||||
return {
|
||||
startMs: now - (1000 * 60 * 60),
|
||||
endMs: now + (1000 * 60 * 60),
|
||||
};
|
||||
}
|
||||
|
||||
const startMs = Math.min(...timestamps);
|
||||
const endMs = Math.max(...timestamps);
|
||||
if (endMs === startMs) {
|
||||
return {
|
||||
startMs,
|
||||
endMs: startMs + (1000 * 60 * 60),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
startMs,
|
||||
endMs,
|
||||
};
|
||||
});
|
||||
|
||||
const totalDurationMs = computed(() => {
|
||||
return Math.max(1, normalizedTimeRange.value.endMs - normalizedTimeRange.value.startMs);
|
||||
});
|
||||
|
||||
const trackCount = computed(() => props.tracks.length);
|
||||
|
||||
const chartWidth = computed(() => {
|
||||
const hours = totalDurationMs.value / (1000 * 60 * 60);
|
||||
const estimated = Math.round(hours * 36);
|
||||
return Math.max(props.minChartWidth, estimated);
|
||||
});
|
||||
|
||||
const svgHeight = computed(() => AXIS_HEIGHT + trackCount.value * props.trackRowHeight + 2);
|
||||
|
||||
function rowTopByIndex(index) {
|
||||
return AXIS_HEIGHT + index * props.trackRowHeight;
|
||||
}
|
||||
|
||||
function xByTimestamp(timestamp) {
|
||||
return ((timestamp - normalizedTimeRange.value.startMs) / totalDurationMs.value) * chartWidth.value;
|
||||
}
|
||||
|
||||
function normalizeBar(bar) {
|
||||
const startMs = toTimestamp(bar?.start);
|
||||
const endMs = toTimestamp(bar?.end);
|
||||
if (startMs === null || endMs === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeEndMs = endMs > startMs ? endMs : startMs + (1000 * 60);
|
||||
return {
|
||||
...bar,
|
||||
startMs,
|
||||
endMs: safeEndMs,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(event) {
|
||||
const timeMs = toTimestamp(event?.time);
|
||||
if (timeMs === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...event,
|
||||
timeMs,
|
||||
};
|
||||
}
|
||||
|
||||
const timelineTicks = computed(() => {
|
||||
const ticks = [];
|
||||
const rangeMs = totalDurationMs.value;
|
||||
const rangeHours = rangeMs / (1000 * 60 * 60);
|
||||
|
||||
const stepMs = rangeHours <= 48
|
||||
? (1000 * 60 * 60)
|
||||
: (1000 * 60 * 60 * 24);
|
||||
|
||||
const start = normalizedTimeRange.value.startMs;
|
||||
const end = normalizedTimeRange.value.endMs;
|
||||
|
||||
let cursor = start;
|
||||
while (cursor <= end) {
|
||||
const date = new Date(cursor);
|
||||
const label = stepMs < (1000 * 60 * 60 * 24)
|
||||
? `${String(date.getHours()).padStart(2, '0')}:00`
|
||||
: `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
|
||||
ticks.push({
|
||||
timeMs: cursor,
|
||||
label,
|
||||
});
|
||||
|
||||
cursor += stepMs;
|
||||
}
|
||||
|
||||
if (ticks.length < 2) {
|
||||
ticks.push({
|
||||
timeMs: end,
|
||||
label: stepMs < (1000 * 60 * 60 * 24) ? 'End' : '結束',
|
||||
});
|
||||
}
|
||||
|
||||
return ticks;
|
||||
});
|
||||
|
||||
const colorFallback = Object.freeze({
|
||||
default: '#94a3b8',
|
||||
});
|
||||
|
||||
function resolveColor(type) {
|
||||
const key = normalizeText(type);
|
||||
if (key && props.colorMap[key]) {
|
||||
return props.colorMap[key];
|
||||
}
|
||||
return colorFallback.default;
|
||||
}
|
||||
|
||||
function layerGeometry(trackIndex, layerIndex, layerCount) {
|
||||
const rowTop = rowTopByIndex(trackIndex);
|
||||
const maxBarHeight = Math.max(16, props.trackRowHeight - 14);
|
||||
const scale = layerCount <= 1
|
||||
? 1
|
||||
: (layerIndex === 0 ? 1 : Math.max(0.4, 1 - layerIndex * 0.2));
|
||||
|
||||
const height = maxBarHeight * scale;
|
||||
const y = rowTop + 6 + ((maxBarHeight - height) / 2);
|
||||
|
||||
return {
|
||||
y,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
const legendItems = computed(() => {
|
||||
const usedTypes = new Set();
|
||||
|
||||
props.tracks.forEach((track) => {
|
||||
const layers = Array.isArray(track?.layers) ? track.layers : [];
|
||||
layers.forEach((layer) => {
|
||||
const bars = Array.isArray(layer?.bars) ? layer.bars : [];
|
||||
bars.forEach((bar) => {
|
||||
const key = normalizeText(bar?.type);
|
||||
if (key) {
|
||||
usedTypes.add(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
props.events.forEach((event) => {
|
||||
const key = normalizeText(event?.type);
|
||||
if (key) {
|
||||
usedTypes.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
const keys = usedTypes.size > 0 ? [...usedTypes] : Object.keys(props.colorMap);
|
||||
return keys.map((key) => ({
|
||||
key,
|
||||
color: resolveColor(key),
|
||||
}));
|
||||
});
|
||||
|
||||
function showTooltip(event, title, lines = []) {
|
||||
const host = containerRef.value;
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = host.getBoundingClientRect();
|
||||
tooltipRef.value = {
|
||||
visible: true,
|
||||
x: event.clientX - bounds.left + 12,
|
||||
y: event.clientY - bounds.top + 12,
|
||||
title,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
tooltipRef.value.visible = false;
|
||||
}
|
||||
|
||||
function handleBarHover(mouseEvent, bar, trackLabel) {
|
||||
const normalized = normalizeBar(bar);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const start = formatDateTime(normalized.start);
|
||||
const end = formatDateTime(normalized.end);
|
||||
const durationHours = ((normalized.endMs - normalized.startMs) / (1000 * 60 * 60)).toFixed(2);
|
||||
|
||||
const title = normalizeText(normalized.label) || normalizeText(normalized.type) || '區段';
|
||||
const lines = [
|
||||
`Track: ${trackLabel}`,
|
||||
`Start: ${start}`,
|
||||
`End: ${end}`,
|
||||
`Duration: ${durationHours}h`,
|
||||
normalizeText(normalized.detail),
|
||||
].filter(Boolean);
|
||||
|
||||
showTooltip(mouseEvent, title, lines);
|
||||
}
|
||||
|
||||
function handleEventHover(mouseEvent, eventItem, trackLabel) {
|
||||
const normalized = normalizeEvent(eventItem);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = normalizeText(normalized.label) || normalizeText(normalized.type) || '事件';
|
||||
const lines = [
|
||||
`Track: ${trackLabel}`,
|
||||
`Time: ${formatDateTime(normalized.time)}`,
|
||||
normalizeText(normalized.detail),
|
||||
].filter(Boolean);
|
||||
|
||||
showTooltip(mouseEvent, title, lines);
|
||||
}
|
||||
|
||||
function eventPath(type, x, y) {
|
||||
const normalizedType = normalizeText(type).toLowerCase();
|
||||
|
||||
if (normalizedType.includes('job') || normalizedType.includes('maint')) {
|
||||
return `M ${x} ${y - 7} L ${x - 7} ${y + 5} L ${x + 7} ${y + 5} Z`;
|
||||
}
|
||||
|
||||
return `M ${x} ${y - 7} L ${x - 7} ${y} L ${x} ${y + 7} L ${x + 7} ${y} Z`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div class="mb-3 flex flex-wrap items-center gap-3 text-xs text-slate-600">
|
||||
<span class="font-medium text-slate-700">Timeline</span>
|
||||
<div v-for="item in legendItems" :key="item.key" class="flex items-center gap-1">
|
||||
<span class="inline-block size-2 rounded-full" :style="{ backgroundColor: item.color }" />
|
||||
<span>{{ item.key }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative overflow-hidden rounded-card border border-stroke-soft bg-surface-muted/30"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<div class="grid" :style="{ gridTemplateColumns: `${labelWidth}px minmax(0, 1fr)` }">
|
||||
<div class="sticky left-0 z-20 border-r border-stroke-soft bg-white">
|
||||
<div class="flex h-[42px] items-center border-b border-stroke-soft px-3 text-xs font-semibold tracking-wide text-slate-500">
|
||||
Track
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="track in tracks"
|
||||
:key="track.id || track.label"
|
||||
class="flex items-center border-b border-stroke-soft/70 px-3 text-xs text-slate-700"
|
||||
:style="{ height: `${trackRowHeight}px` }"
|
||||
>
|
||||
<span class="line-clamp-1">{{ track.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<svg
|
||||
:width="chartWidth"
|
||||
:height="svgHeight"
|
||||
:viewBox="`0 0 ${chartWidth} ${svgHeight}`"
|
||||
class="block"
|
||||
>
|
||||
<rect x="0" y="0" :width="chartWidth" :height="svgHeight" fill="#ffffff" />
|
||||
|
||||
<g>
|
||||
<line x1="0" :x2="chartWidth" y1="41" y2="41" stroke="#cbd5e1" stroke-width="1" />
|
||||
<g v-for="tick in timelineTicks" :key="tick.timeMs">
|
||||
<line
|
||||
:x1="xByTimestamp(tick.timeMs)"
|
||||
:x2="xByTimestamp(tick.timeMs)"
|
||||
y1="0"
|
||||
:y2="svgHeight"
|
||||
stroke="#e2e8f0"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="2 3"
|
||||
/>
|
||||
<text
|
||||
:x="xByTimestamp(tick.timeMs) + 2"
|
||||
y="14"
|
||||
fill="#475569"
|
||||
font-size="11"
|
||||
>
|
||||
{{ tick.label }}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g v-for="(track, trackIndex) in tracks" :key="track.id || track.label">
|
||||
<rect
|
||||
x="0"
|
||||
:y="rowTopByIndex(trackIndex)"
|
||||
:width="chartWidth"
|
||||
:height="trackRowHeight"
|
||||
:fill="trackIndex % 2 === 0 ? '#f8fafc' : '#f1f5f9'"
|
||||
opacity="0.45"
|
||||
/>
|
||||
|
||||
<g v-for="(layer, layerIndex) in (track.layers || [])" :key="layer.id || layerIndex">
|
||||
<template
|
||||
v-for="(bar, barIndex) in (layer.bars || [])"
|
||||
:key="bar.id || `${trackIndex}-${layerIndex}-${barIndex}`"
|
||||
>
|
||||
<rect
|
||||
v-if="normalizeBar(bar)"
|
||||
:x="xByTimestamp(normalizeBar(bar).startMs)"
|
||||
:y="layerGeometry(trackIndex, layerIndex, (track.layers || []).length).y"
|
||||
:width="Math.max(2, xByTimestamp(normalizeBar(bar).endMs) - xByTimestamp(normalizeBar(bar).startMs))"
|
||||
:height="layerGeometry(trackIndex, layerIndex, (track.layers || []).length).height"
|
||||
:fill="bar.color || resolveColor(bar.type)"
|
||||
:opacity="layer.opacity ?? (layerIndex === 0 ? 0.45 : 0.9)"
|
||||
rx="3"
|
||||
@mousemove="handleBarHover($event, bar, track.label)"
|
||||
/>
|
||||
</template>
|
||||
</g>
|
||||
|
||||
<template v-for="(eventItem, eventIndex) in events" :key="eventItem.id || `${trackIndex}-event-${eventIndex}`">
|
||||
<path
|
||||
v-if="normalizeEvent(eventItem) && normalizeText(eventItem.trackId) === normalizeText(track.id)"
|
||||
:d="eventPath(eventItem.shape || eventItem.type, xByTimestamp(normalizeEvent(eventItem).timeMs), rowTopByIndex(trackIndex) + (trackRowHeight / 2))"
|
||||
:fill="eventItem.color || resolveColor(eventItem.type)"
|
||||
stroke="#0f172a"
|
||||
stroke-width="0.5"
|
||||
@mousemove="handleEventHover($event, eventItem, track.label)"
|
||||
/>
|
||||
</template>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="tooltipRef.visible"
|
||||
class="pointer-events-none absolute z-30 max-w-72 rounded-card border border-stroke-soft bg-slate-900/95 px-2 py-1.5 text-[11px] text-slate-100 shadow-lg"
|
||||
:style="{ left: `${tooltipRef.x}px`, top: `${tooltipRef.y}px` }"
|
||||
>
|
||||
<p class="font-semibold text-white">{{ tooltipRef.title }}</p>
|
||||
<p v-for="line in tooltipRef.lines" :key="line" class="mt-0.5 text-slate-200">{{ line }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user