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:
egg
2026-02-13 15:25:00 +08:00
parent 653900dc15
commit 5b358d71c1
56 changed files with 7458 additions and 6201 deletions

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

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

View 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%)`;
}

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