Files
DashBoard/frontend/src/query-tool/components/LotJobsTable.vue
egg 71c8102de6 feat: dataset cache for hold/resource history + slow connection migration
Two changes combined:

1. historical-query-slow-connection: Migrate all historical query pages
   to read_sql_df_slow with semaphore concurrency control (max 3),
   raise DB slow timeout to 300s, gunicorn timeout to 360s, and
   unify frontend timeouts to 360s for all historical pages.

2. hold-resource-history-dataset-cache: Convert hold-history and
   resource-history from multi-query to single-query + dataset cache
   pattern (L1 ProcessLevelCache + L2 Redis parquet/base64, TTL=900s).
   Replace old GET endpoints with POST /query + GET /view two-phase
   API. Frontend auto-retries on 410 cache_expired.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:15:02 +08:00

247 lines
6.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { computed, ref } from 'vue';
import { apiGet, ensureMesApiAvailable } from '../../core/api.js';
import StatusBadge from '../../shared-ui/components/StatusBadge.vue';
import { formatCellValue, formatDateTime, parseDateTime } from '../utils/values.js';
const props = defineProps({
rows: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
emptyText: {
type: String,
default: '無維修資料',
},
});
ensureMesApiAvailable();
const JOB_COLUMN_PRIORITY = Object.freeze([
'JOBID',
'RESOURCENAME',
'JOBSTATUS',
'JOBMODELNAME',
'JOBORDERNAME',
'CREATEDATE',
'COMPLETEDATE',
'CANCELDATE',
'FIRSTCLOCKONDATE',
'LASTCLOCKOFFDATE',
'CAUSECODENAME',
'REPAIRCODENAME',
'SYMPTOMCODENAME',
'PJ_CAUSECODE2NAME',
'PJ_REPAIRCODE2NAME',
'PJ_SYMPTOMCODE2NAME',
'CREATE_EMPNAME',
'COMPLETE_EMPNAME',
'CONTAINERNAMES',
]);
const TXN_COLUMN_PRIORITY = Object.freeze([
'TXNDATE',
'FROMJOBSTATUS',
'JOBSTATUS',
'STAGENAME',
'CAUSECODENAME',
'REPAIRCODENAME',
'USER_NAME',
'COMMENTS',
]);
const selectedJobId = ref('');
const txnRows = ref([]);
const loadingTxn = ref(false);
const txnError = ref('');
function buildOrderedColumns(rows, preferred) {
const keys = new Set(Object.keys(rows?.[0] || {}));
return preferred.filter((column) => keys.has(column));
}
const sortedRows = computed(() => {
return [...(props.rows || [])].sort((a, b) => {
const aDate = parseDateTime(a?.CREATEDATE);
const bDate = parseDateTime(b?.CREATEDATE);
const aTime = aDate ? aDate.getTime() : 0;
const bTime = bDate ? bDate.getTime() : 0;
return bTime - aTime;
});
});
const jobColumns = computed(() => {
return buildOrderedColumns(props.rows, JOB_COLUMN_PRIORITY);
});
const txnColumns = computed(() => {
return buildOrderedColumns(txnRows.value, TXN_COLUMN_PRIORITY);
});
function rowKey(row, index) {
return String(row?.JOBID || `${row?.RESOURCEID || ''}-${index}`);
}
function buildStatusTone(status) {
const text = String(status || '').trim().toLowerCase();
if (!text) {
return 'neutral';
}
if (['complete', 'completed', 'done', 'closed', 'finish'].some((keyword) => text.includes(keyword))) {
return 'success';
}
if (['open', 'pending', 'queue', 'wait', 'hold', 'in progress'].some((keyword) => text.includes(keyword))) {
return 'warning';
}
if (['cancel', 'abort', 'fail', 'error'].some((keyword) => text.includes(keyword))) {
return 'danger';
}
return 'neutral';
}
function renderJobCellValue(row, column) {
if (column === 'CREATEDATE' || column === 'COMPLETEDATE') {
return formatDateTime(row?.[column]);
}
return formatCellValue(row?.[column]);
}
function renderTxnCellValue(row, column) {
const normalizedColumn = String(column || '').toUpperCase();
if (normalizedColumn.includes('DATE') || normalizedColumn.includes('TIME')) {
return formatDateTime(row?.[column]);
}
if (column === 'USER_NAME') {
return formatCellValue(row?.USER_NAME || row?.EMP_NAME);
}
return formatCellValue(row?.[column]);
}
async function loadTxn(jobId) {
const id = String(jobId || '').trim();
if (!id) {
return;
}
selectedJobId.value = id;
loadingTxn.value = true;
txnError.value = '';
txnRows.value = [];
try {
const payload = await apiGet(`/api/job-query/txn/${encodeURIComponent(id)}`, {
timeout: 360000,
silent: true,
});
txnRows.value = Array.isArray(payload?.data) ? payload.data : [];
} catch (error) {
txnError.value = error?.message || '載入交易歷程失敗';
txnRows.value = [];
} finally {
loadingTxn.value = false;
}
}
</script>
<template>
<section class="space-y-3">
<div>
<div v-if="loading" class="placeholder">
讀取中...
</div>
<div v-else-if="sortedRows.length === 0" class="placeholder">
{{ emptyText }}
</div>
<div v-else class="query-tool-table-wrap">
<table class="query-tool-table">
<thead>
<tr>
<th>操作</th>
<th v-for="column in jobColumns" :key="column">
{{ column === 'CONTAINERNAMES' ? 'LOT ID' : column }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in sortedRows" :key="rowKey(row, rowIndex)">
<td>
<button
type="button"
class="btn btn-ghost"
style="padding: 2px 8px; font-size: 11px"
@click="loadTxn(row?.JOBID)"
>
查看交易歷程
</button>
</td>
<td v-for="column in jobColumns" :key="`${rowKey(row, rowIndex)}-${column}`">
<StatusBadge
v-if="column === 'JOBSTATUS'"
:tone="buildStatusTone(row?.[column])"
:text="formatCellValue(row?.[column])"
/>
<span v-else>{{ renderJobCellValue(row, column) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="selectedJobId">
<div class="query-tool-section-header">
<h4 class="card-title">交易歷程{{ selectedJobId }}</h4>
<span class="query-tool-muted">{{ txnRows.length }} </span>
</div>
<p v-if="txnError" class="error-banner">
{{ txnError }}
</p>
<div v-if="loadingTxn" class="placeholder">
載入交易歷程中...
</div>
<div v-else-if="txnRows.length === 0" class="placeholder">
無交易歷程資料
</div>
<div v-else class="query-tool-table-wrap">
<table class="query-tool-table">
<thead>
<tr>
<th v-for="column in txnColumns" :key="column">
{{ column }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIndex) in txnRows"
:key="row?.JOBTXNHISTORYID || `${selectedJobId}-${rowIndex}`"
>
<td v-for="column in txnColumns" :key="`${row?.JOBTXNHISTORYID || rowIndex}-${column}`">
<StatusBadge
v-if="column === 'JOBSTATUS' || column === 'FROMJOBSTATUS'"
:tone="buildStatusTone(row?.[column])"
:text="formatCellValue(row?.[column])"
/>
<span v-else>{{ renderTxnCellValue(row, column) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</template>