feat(resource-status): enrich LOT tooltip with product/material info and draggable header

Add WIP detail API integration to FloatingTooltip for LOT popups, displaying
product info (Product, Product Line, Package, Workorder) and material info
(Wafer Lot ID, Wafer P/N, Leadframe, Compound) with client-side caching.
Make the tooltip header draggable for both LOT and JOB popups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-12 08:40:21 +08:00
parent 8550f6dc3e
commit 2c8d80afe6
2 changed files with 151 additions and 5 deletions

View File

@@ -1,6 +1,8 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue';
import { apiGet } from '../../core/api.js';
const props = defineProps({
visible: {
type: Boolean,
@@ -24,6 +26,9 @@ const emit = defineEmits(['close']);
const tooltipRef = ref(null);
const tooltipStyle = reactive({ left: '0px', top: '0px' });
const lotDetailMap = ref({});
const isDragging = ref(false);
const dragOffset = { x: 0, y: 0 };
const tooltipTitle = computed(() => {
if (props.type === 'job') {
@@ -39,6 +44,48 @@ const lotItems = computed(() => {
return props.payload;
});
async function fetchLotDetails(lots) {
const ids = lots.map((lot) => lot.RUNCARDLOTID).filter(Boolean);
if (ids.length === 0) {
return;
}
const pending = ids.filter((id) => !lotDetailMap.value[id]);
if (pending.length === 0) {
return;
}
const results = await Promise.allSettled(
pending.map((id) =>
apiGet(`/api/wip/lot/${encodeURIComponent(id)}`, { timeout: 15000 })
.then((result) => {
const data = result?.success ? result.data : result?.data !== undefined ? result.data : result;
return { id, data };
}),
),
);
const updated = { ...lotDetailMap.value };
for (const entry of results) {
if (entry.status === 'fulfilled' && entry.value?.data) {
updated[entry.value.id] = entry.value.data;
}
}
lotDetailMap.value = updated;
}
function getLotDetail(lotId) {
return lotDetailMap.value[lotId] || null;
}
function lotDetailValue(detail, key) {
const value = detail?.[key];
if (value === null || value === undefined || value === '') {
return '--';
}
return String(value);
}
const jobFields = computed(() => {
if (!props.payload || Array.isArray(props.payload)) {
return [];
@@ -106,6 +153,38 @@ function handleOutsideClick(event) {
}
}
function onDragStart(event) {
if (event.button !== 0) {
return;
}
isDragging.value = true;
dragOffset.x = event.clientX - parseFloat(tooltipStyle.left);
dragOffset.y = event.clientY - parseFloat(tooltipStyle.top);
document.addEventListener('mousemove', onDragMove);
document.addEventListener('mouseup', onDragEnd);
}
function onDragMove(event) {
if (!isDragging.value) {
return;
}
const padding = 10;
let nextX = event.clientX - dragOffset.x;
let nextY = event.clientY - dragOffset.y;
nextX = Math.max(padding, Math.min(nextX, window.innerWidth - padding));
nextY = Math.max(padding, Math.min(nextY, window.innerHeight - padding));
tooltipStyle.left = `${nextX}px`;
tooltipStyle.top = `${nextY}px`;
}
function onDragEnd() {
isDragging.value = false;
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragEnd);
}
function bindOverlayListeners() {
document.addEventListener('click', handleOutsideClick, true);
window.addEventListener('resize', positionTooltip);
@@ -114,6 +193,8 @@ function bindOverlayListeners() {
function unbindOverlayListeners() {
document.removeEventListener('click', handleOutsideClick, true);
window.removeEventListener('resize', positionTooltip);
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragEnd);
}
watch(
@@ -127,6 +208,10 @@ watch(
await nextTick();
positionTooltip();
bindOverlayListeners();
if (props.type === 'lot' && Array.isArray(props.payload) && props.payload.length > 0) {
void fetchLotDetails(props.payload);
}
}
);
@@ -138,7 +223,7 @@ onBeforeUnmount(() => {
<template>
<Teleport to="body">
<div v-if="visible" ref="tooltipRef" class="floating-tooltip" :style="tooltipStyle" @click.stop>
<div class="floating-tooltip-header">
<div class="floating-tooltip-header" :class="{ dragging: isDragging }" @mousedown="onDragStart">
<h3 class="floating-tooltip-title">{{ tooltipTitle }}</h3>
<button type="button" class="floating-tooltip-close" @click="$emit('close')">&times;</button>
</div>
@@ -157,11 +242,50 @@ onBeforeUnmount(() => {
<span class="tooltip-field-label">Track-in</span>
<span class="tooltip-field-value">{{ formatDate(lot.LOTTRACKINTIME) }}</span>
</div>
<div class="tooltip-field">
<span class="tooltip-field-label">Employee</span>
<span class="tooltip-field-value">{{ lot.LOTTRACKINEMPLOYEE || '--' }}</span>
</div>
</div>
<template v-if="getLotDetail(lot.RUNCARDLOTID)">
<div class="lot-section-label">產品資訊</div>
<div class="lot-grid">
<div class="tooltip-field">
<span class="tooltip-field-label">Product</span>
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'product') }}</span>
</div>
<div class="tooltip-field">
<span class="tooltip-field-label">Product Line</span>
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'productLine') }}</span>
</div>
<div class="tooltip-field">
<span class="tooltip-field-label">Package</span>
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'packageLef') }}</span>
</div>
<div class="tooltip-field">
<span class="tooltip-field-label">Workorder</span>
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'workorder') }}</span>
</div>
</div>
<div class="lot-section-label">物料資訊</div>
<div class="lot-grid">
<div class="tooltip-field">
<span class="tooltip-field-label">Wafer Lot ID</span>
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'waferLotId') }}</span>
</div>
<div class="tooltip-field">
<span class="tooltip-field-label">Wafer P/N</span>
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'waferPn') }}</span>
</div>
<div class="tooltip-field">
<span class="tooltip-field-label">Leadframe</span>
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'leadframeName') }}</span>
</div>
<div class="tooltip-field">
<span class="tooltip-field-label">Compound</span>
<span class="tooltip-field-value">{{ lotDetailValue(getLotDetail(lot.RUNCARDLOTID), 'compoundName') }}</span>
</div>
</div>
</template>
<div v-else-if="lot.RUNCARDLOTID" class="lot-detail-loading-hint">載入詳細資料中...</div>
</article>
</template>
<div v-else class="tooltip-empty"> LOT 明細</div>

View File

@@ -222,6 +222,12 @@
padding: 10px 12px;
background: #eff6ff;
border-bottom: 1px solid #dbeafe;
cursor: grab;
user-select: none;
}
.floating-tooltip-header.dragging {
cursor: grabbing;
}
.floating-tooltip-title {
@@ -265,6 +271,22 @@
margin-bottom: 6px;
}
.lot-section-label {
font-size: 11px;
font-weight: 700;
color: #475569;
margin-top: 8px;
margin-bottom: 2px;
padding-top: 6px;
border-top: 1px solid #e2e8f0;
}
.lot-detail-loading-hint {
font-size: 11px;
color: #94a3b8;
margin-top: 6px;
}
.lot-grid,
.job-grid {
display: grid;