Files
DashBoard/frontend/src/resource-status/App.vue
egg 7b3f4b2cc1 feat(resource): add cascade machine/family filters to status and history pages
Add interdependent filter controls where upstream filters (workcenter group,
boolean flags) dynamically narrow downstream options (family, machine).
MultiSelect component moved to resource-shared with searchable support.
Backend endpoints accept families and resource_ids params, leveraging
existing Redis-cached resource metadata for client-side cascade filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:44:04 +08:00

497 lines
13 KiB
Vue

<script setup>
import { computed, reactive, ref } from 'vue';
import { apiGet, ensureMesApiAvailable } from '../core/api.js';
import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js';
import { MATRIX_STATUS_COLUMNS, STATUS_DISPLAY_MAP, normalizeStatus } from '../resource-shared/constants.js';
import EquipmentGrid from './components/EquipmentGrid.vue';
import FilterBar from './components/FilterBar.vue';
import FloatingTooltip from './components/FloatingTooltip.vue';
import MatrixSection from './components/MatrixSection.vue';
import StatusHeader from './components/StatusHeader.vue';
import SummaryCards from './components/SummaryCards.vue';
ensureMesApiAvailable();
const API_TIMEOUT = 60000;
const allEquipment = ref([]);
const workcenterGroups = ref([]);
const allResources = ref([]);
const summary = ref({
totalCount: 0,
byStatus: {
PRD: 0,
SBY: 0,
UDT: 0,
SDT: 0,
EGT: 0,
NST: 0,
OTHER: 0,
},
ouPct: 0,
availabilityPct: 0,
});
const filterState = reactive({
group: '',
isProduction: false,
isKey: false,
isMonitor: false,
families: [],
machines: [],
});
const matrixFilter = ref([]);
const summaryStatusFilter = ref(null);
const hierarchyState = reactive({});
const loading = reactive({
initial: true,
refreshing: false,
options: false,
});
const cacheLevel = ref('loading');
const cacheText = ref('檢查中...');
const lastUpdate = ref('--');
const summaryError = ref('');
const equipmentError = ref('');
const tooltipState = reactive({
visible: false,
type: 'lot',
payload: null,
position: { x: 0, y: 0 },
});
function unwrapApiResult(result, fallbackMessage) {
if (result?.success === true) {
return result.data;
}
if (result?.success === false) {
throw new Error(result.error || fallbackMessage);
}
return result;
}
function buildFilterParams() {
const params = {};
if (filterState.group) {
params.workcenter_groups = filterState.group;
}
if (filterState.isProduction) {
params.is_production = 1;
}
if (filterState.isKey) {
params.is_key = 1;
}
if (filterState.isMonitor) {
params.is_monitor = 1;
}
if (filterState.families.length) {
params.families = filterState.families.join(',');
}
if (filterState.machines.length) {
params.resource_ids = filterState.machines.join(',');
}
return params;
}
// --- Cascade: derive available family/machine options from upstream filters ---
const filteredByUpstream = computed(() => {
return allResources.value.filter((r) => {
if (filterState.group && r.workcenterGroup !== filterState.group) return false;
if (filterState.isProduction && !r.isProduction) return false;
if (filterState.isKey && !r.isKey) return false;
if (filterState.isMonitor && !r.isMonitor) return false;
return true;
});
});
const familyOptions = computed(() => {
const set = new Set();
filteredByUpstream.value.forEach((r) => {
if (r.family) set.add(r.family);
});
return [...set].sort();
});
const machineOptions = computed(() => {
let list = filteredByUpstream.value;
if (filterState.families.length > 0) {
const fset = new Set(filterState.families);
list = list.filter((r) => fset.has(r.family));
}
return list
.map((r) => ({ label: r.name, value: r.id }))
.sort((a, b) => a.label.localeCompare(b.label));
});
function pruneInvalidSelections() {
const validFamilies = new Set(familyOptions.value);
filterState.families = filterState.families.filter((f) => validFamilies.has(f));
const validMachineIds = new Set(machineOptions.value.map((m) => m.value));
filterState.machines = filterState.machines.filter((m) => validMachineIds.has(m));
}
function resetHierarchyState() {
Object.keys(hierarchyState).forEach((key) => {
delete hierarchyState[key];
});
}
async function loadOptions() {
loading.options = true;
try {
const result = await apiGet('/api/resource/status/options', {
timeout: API_TIMEOUT,
silent: true,
});
const data = unwrapApiResult(result, '載入篩選選項失敗');
workcenterGroups.value = Array.isArray(data?.workcenter_groups) ? data.workcenter_groups : [];
allResources.value = Array.isArray(data?.resources) ? data.resources : [];
} finally {
loading.options = false;
}
}
async function loadSummary() {
const result = await apiGet('/api/resource/status/summary', {
params: buildFilterParams(),
timeout: API_TIMEOUT,
silent: true,
});
const data = unwrapApiResult(result, '載入摘要失敗');
const byStatus = data?.by_status || {};
const normalizedStatus = Object.fromEntries(
MATRIX_STATUS_COLUMNS.map((status) => [status, Number(byStatus[status] || 0)])
);
summary.value = {
totalCount: Number(data?.total_count || 0),
byStatus: normalizedStatus,
ouPct: Number(data?.ou_pct || 0),
availabilityPct: Number(data?.availability_pct || 0),
};
}
async function loadEquipment() {
const result = await apiGet('/api/resource/status', {
params: buildFilterParams(),
timeout: API_TIMEOUT,
silent: true,
});
const data = unwrapApiResult(result, '載入設備資料失敗');
allEquipment.value = Array.isArray(data) ? data : [];
matrixFilter.value = [];
summaryStatusFilter.value = null;
resetHierarchyState();
}
async function checkCacheStatus() {
try {
const health = await apiGet('/health', {
timeout: 15000,
retries: 0,
silent: true,
});
const resourceCache = health?.resource_cache || {};
const equipmentCache = health?.equipment_status_cache || {};
if (resourceCache.enabled && resourceCache.loaded) {
cacheLevel.value = 'ok';
cacheText.value = `快取正常 (${Number(resourceCache.count || 0)} 筆)`;
} else if (resourceCache.enabled) {
cacheLevel.value = 'loading';
cacheText.value = '快取載入中...';
} else {
cacheLevel.value = 'error';
cacheText.value = '快取未啟用';
}
if (equipmentCache.updated_at) {
lastUpdate.value = new Date(equipmentCache.updated_at).toLocaleString('zh-TW');
} else {
lastUpdate.value = '--';
}
} catch {
cacheLevel.value = 'error';
cacheText.value = '無法連線';
lastUpdate.value = '--';
}
}
function buildSingleFilterLabel(filter) {
const parts = [filter.workcenter_group];
if (filter.family) {
parts.push(filter.family);
}
if (filter.resource) {
const resource = allEquipment.value.find((item) => item.RESOURCEID === filter.resource);
parts.push(resource?.RESOURCENAME || filter.resource);
}
parts.push(STATUS_DISPLAY_MAP[filter.status] || filter.status);
return parts.join(' / ');
}
function filterKey(f) {
return `${f.workcenter_group}|${f.status}|${f.family || ''}|${f.resource || ''}`;
}
function matchSingleFilter(eq, filter) {
if ((eq.WORKCENTER_GROUP || 'UNKNOWN') !== filter.workcenter_group) {
return false;
}
if (filter.family && (eq.RESOURCEFAMILYNAME || 'UNKNOWN') !== filter.family) {
return false;
}
if (filter.resource && (eq.RESOURCEID || null) !== filter.resource) {
return false;
}
return normalizeStatus(eq.EQUIPMENTASSETSSTATUS) === filter.status;
}
const displayedEquipment = computed(() => {
const filters = matrixFilter.value;
return allEquipment.value.filter((eq) => {
if (filters.length > 0 && !filters.some((f) => matchSingleFilter(eq, f))) {
return false;
}
if (summaryStatusFilter.value && normalizeStatus(eq.EQUIPMENTASSETSSTATUS) !== summaryStatusFilter.value) {
return false;
}
return true;
});
});
const activeFilterText = computed(() => {
const labels = [];
if (matrixFilter.value.length > 0) {
const parts = matrixFilter.value.map(buildSingleFilterLabel);
labels.push(`矩陣篩選: ${parts.join(' + ')}`);
}
if (summaryStatusFilter.value) {
labels.push(`卡片篩選: ${STATUS_DISPLAY_MAP[summaryStatusFilter.value] || summaryStatusFilter.value}`);
}
return labels.join(' | ');
});
function applyMatrixFilter(nextFilter) {
const entry = {
workcenter_group: nextFilter.workcenter_group,
status: nextFilter.status,
family: nextFilter.family || null,
resource: nextFilter.resource || null,
};
const key = filterKey(entry);
const idx = matrixFilter.value.findIndex((f) => filterKey(f) === key);
if (idx >= 0) {
matrixFilter.value = matrixFilter.value.filter((_, i) => i !== idx);
} else {
matrixFilter.value = [...matrixFilter.value, entry];
}
}
function clearAllEquipmentFilters() {
matrixFilter.value = [];
summaryStatusFilter.value = null;
}
function toggleSummaryStatus(status) {
summaryStatusFilter.value = summaryStatusFilter.value === status ? null : status;
}
function handleToggleRow(rowId) {
hierarchyState[rowId] = !hierarchyState[rowId];
}
function handleToggleAllRows({ expand, rowIds }) {
(rowIds || []).forEach((rowId) => {
hierarchyState[rowId] = Boolean(expand);
});
}
function closeTooltip() {
tooltipState.visible = false;
tooltipState.payload = null;
}
function openLotTooltip({ x, y, equipment }) {
const lotDetails = equipment?.LOT_DETAILS;
if (!Array.isArray(lotDetails) || lotDetails.length === 0) {
return;
}
tooltipState.type = 'lot';
tooltipState.payload = lotDetails;
tooltipState.position = { x, y };
tooltipState.visible = true;
}
function openJobTooltip({ x, y, equipment }) {
if (!equipment?.JOBORDER) {
return;
}
tooltipState.type = 'job';
tooltipState.payload = equipment;
tooltipState.position = { x, y };
tooltipState.visible = true;
}
async function loadData(showOverlay = false) {
if (showOverlay) {
loading.initial = true;
}
loading.refreshing = true;
summaryError.value = '';
equipmentError.value = '';
const [summaryResult, equipmentResult] = await Promise.allSettled([loadSummary(), loadEquipment()]);
await checkCacheStatus();
if (summaryResult.status === 'rejected') {
summaryError.value = summaryResult.reason?.message || '摘要資料載入失敗';
}
if (equipmentResult.status === 'rejected') {
equipmentError.value = equipmentResult.reason?.message || '設備資料載入失敗';
allEquipment.value = [];
}
loading.refreshing = false;
loading.initial = false;
}
async function applyFiltersAndReload() {
closeTooltip();
await loadData(false);
resetAutoRefresh();
}
function updateGroup(group) {
filterState.group = group || '';
pruneInvalidSelections();
void applyFiltersAndReload();
}
function updateFlags(nextFlags) {
filterState.isProduction = Boolean(nextFlags?.isProduction);
filterState.isKey = Boolean(nextFlags?.isKey);
filterState.isMonitor = Boolean(nextFlags?.isMonitor);
pruneInvalidSelections();
void applyFiltersAndReload();
}
function updateFamilies(families) {
filterState.families = families || [];
pruneInvalidSelections();
void applyFiltersAndReload();
}
function updateMachines(machines) {
filterState.machines = machines || [];
void applyFiltersAndReload();
}
const { resetAutoRefresh, triggerRefresh } = useAutoRefresh({
onRefresh: () => loadData(false),
intervalMs: 5 * 60 * 1000,
autoStart: true,
refreshOnVisible: true,
});
async function handleManualRefresh() {
closeTooltip();
await triggerRefresh({ force: true, resetTimer: true });
}
async function initPage() {
try {
await loadOptions();
} catch (error) {
equipmentError.value = error?.message || '載入篩選選項失敗';
}
await loadData(true);
}
void initPage();
</script>
<template>
<div class="resource-page">
<div class="dashboard">
<StatusHeader
:cache-level="cacheLevel"
:cache-text="cacheText"
:last-update="lastUpdate"
:refreshing="loading.refreshing"
@refresh="handleManualRefresh"
/>
<FilterBar
:workcenter-groups="workcenterGroups"
:selected-group="filterState.group"
:flags="filterState"
:family-options="familyOptions"
:machine-options="machineOptions"
:selected-families="filterState.families"
:selected-machines="filterState.machines"
:loading="loading.options || loading.refreshing"
@change-group="updateGroup"
@change-flags="updateFlags"
@change-families="updateFamilies"
@change-machines="updateMachines"
/>
<p v-if="summaryError" class="error-banner">{{ summaryError }}</p>
<SummaryCards :summary="summary" :active-status="summaryStatusFilter" @toggle-status="toggleSummaryStatus" />
<p v-if="equipmentError" class="error-banner">{{ equipmentError }}</p>
<MatrixSection
:equipment="allEquipment"
:expanded-state="hierarchyState"
:matrix-filter="matrixFilter"
@toggle-row="handleToggleRow"
@toggle-all="handleToggleAllRows"
@cell-filter="applyMatrixFilter"
/>
<EquipmentGrid
:equipment="displayedEquipment"
:active-filter-text="activeFilterText"
@clear-filter="clearAllEquipmentFilters"
@show-lot="openLotTooltip"
@show-job="openJobTooltip"
/>
</div>
<div class="loading-overlay" :class="{ hidden: !loading.initial }">
<div class="loading-spinner"></div>
</div>
<FloatingTooltip
:visible="tooltipState.visible"
:type="tooltipState.type"
:payload="tooltipState.payload"
:position="tooltipState.position"
@close="closeTooltip"
/>
</div>
</template>