1. 修正 Oracle ORA-32034 錯誤 - 將巢狀 WITH 子句改為同層級 CTE 2. Last Update 改用資料庫最新 LASTSTATUSCHANGEDATE,非系統時間 3. Down Time 改為 MAX(LASTSTATUSCHANGEDATE) - 各機台時間差 4. 機台明細表預設只顯示 UDT/SDT (DOWN 狀態) 5. 標題更新為 "DOWN 機台明細" Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
938 lines
33 KiB
HTML
938 lines
33 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-TW">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>全廠機況 Dashboard</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
|
|
|
<style>
|
|
:root {
|
|
--bg: #f5f7fa;
|
|
--card-bg: #ffffff;
|
|
--text: #222;
|
|
--muted: #666;
|
|
--border: #e2e6ef;
|
|
--primary: #667eea;
|
|
--primary-dark: #5568d3;
|
|
--shadow: 0 2px 10px rgba(0,0,0,0.08);
|
|
--shadow-strong: 0 4px 15px rgba(102, 126, 234, 0.2);
|
|
--success: #22c55e;
|
|
--danger: #ef4444;
|
|
--warning: #f59e0b;
|
|
--info: #3b82f6;
|
|
--neutral: #64748b;
|
|
}
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Microsoft JhengHei', Arial, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.dashboard {
|
|
max-width: 1800px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
/* Header */
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 12px;
|
|
padding: 18px 22px;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
border-radius: 10px;
|
|
margin-bottom: 16px;
|
|
box-shadow: var(--shadow-strong);
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 24px;
|
|
color: #fff;
|
|
}
|
|
|
|
.header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.last-update {
|
|
color: rgba(255, 255, 255, 0.8);
|
|
font-size: 13px;
|
|
}
|
|
|
|
/* Filters */
|
|
.filters {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
padding: 10px 14px;
|
|
border-radius: 10px;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.filter-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.filter-group label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
}
|
|
|
|
.filter-group input[type="checkbox"] {
|
|
width: 16px;
|
|
height: 16px;
|
|
cursor: pointer;
|
|
accent-color: var(--primary);
|
|
}
|
|
|
|
.btn-query {
|
|
background: var(--primary);
|
|
color: white;
|
|
border: none;
|
|
padding: 9px 20px;
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.2s ease;
|
|
}
|
|
|
|
.btn-query:hover {
|
|
background: var(--primary-dark);
|
|
}
|
|
|
|
.btn-query:disabled {
|
|
background: #a6b0f5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* KPI Cards */
|
|
.kpi-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(5, 1fr);
|
|
gap: 14px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.kpi-card {
|
|
background: var(--card-bg);
|
|
border-radius: 10px;
|
|
padding: 18px;
|
|
text-align: center;
|
|
border: 1px solid var(--border);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.kpi-label {
|
|
font-size: 13px;
|
|
color: var(--muted);
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.kpi-value {
|
|
font-size: 30px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.kpi-value.green { color: var(--success); }
|
|
.kpi-value.blue { color: var(--primary); }
|
|
.kpi-value.red { color: var(--danger); }
|
|
.kpi-value.yellow { color: var(--warning); }
|
|
|
|
.kpi-sub {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
margin-top: 4px;
|
|
}
|
|
|
|
/* Workcenter Cards Grid */
|
|
.workcenter-section {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 16px;
|
|
color: var(--primary);
|
|
margin-bottom: 10px;
|
|
padding-left: 10px;
|
|
border-left: 3px solid var(--primary);
|
|
}
|
|
|
|
.workcenter-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(6, 1fr);
|
|
gap: 12px;
|
|
}
|
|
|
|
.wc-card {
|
|
background: var(--card-bg);
|
|
border-radius: 8px;
|
|
padding: 14px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
border: 1px solid var(--border);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.wc-card:hover {
|
|
border-color: var(--primary);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.wc-card.selected {
|
|
border-color: var(--success);
|
|
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.25);
|
|
}
|
|
|
|
.wc-card.has-issue {
|
|
border-left: 4px solid var(--danger);
|
|
}
|
|
|
|
.wc-name {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
margin-bottom: 8px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.wc-stats {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.wc-ou {
|
|
font-size: 22px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.wc-ou.high { color: var(--success); }
|
|
.wc-ou.medium { color: var(--warning); }
|
|
.wc-ou.low { color: var(--danger); }
|
|
|
|
.wc-counts {
|
|
text-align: right;
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.wc-counts .down {
|
|
color: var(--danger);
|
|
font-weight: bold;
|
|
}
|
|
|
|
.wc-mini-chart {
|
|
height: 34px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
/* Detail Table */
|
|
.detail-section {
|
|
background: var(--card-bg);
|
|
border-radius: 10px;
|
|
padding: 16px;
|
|
border: 1px solid var(--border);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.detail-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.detail-title {
|
|
font-size: 16px;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.detail-count {
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.table-container {
|
|
overflow-x: auto;
|
|
max-height: 350px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 12px;
|
|
}
|
|
|
|
thead {
|
|
position: sticky;
|
|
top: 0;
|
|
background: #eef2ff;
|
|
z-index: 1;
|
|
}
|
|
|
|
th {
|
|
padding: 10px 8px;
|
|
text-align: left;
|
|
color: #3f4aa7;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
td {
|
|
padding: 8px;
|
|
border-bottom: 1px solid var(--border);
|
|
color: var(--text);
|
|
}
|
|
|
|
tbody tr:hover {
|
|
background: #f1f5ff;
|
|
}
|
|
|
|
.status-badge {
|
|
display: inline-block;
|
|
padding: 3px 10px;
|
|
border-radius: 12px;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.status-prd { background: #bbf7d0; color: #166534; }
|
|
.status-sby { background: #bfdbfe; color: #1e40af; }
|
|
.status-udt { background: #fecaca; color: #991b1b; }
|
|
.status-sdt { background: #fef3c7; color: #92400e; }
|
|
.status-egt { background: #e2e8f0; color: #475569; }
|
|
.status-nst { background: #e9d5ff; color: #6b21a8; }
|
|
.status-other { background: #e5e7eb; color: #374151; }
|
|
|
|
.flag-badge {
|
|
display: inline-block;
|
|
padding: 2px 6px;
|
|
border-radius: 8px;
|
|
font-size: 10px;
|
|
margin-right: 3px;
|
|
}
|
|
|
|
.flag-prod { background: #dcfce7; color: #166534; }
|
|
.flag-key { background: #fee2e2; color: #991b1b; }
|
|
.flag-monitor { background: #dbeafe; color: #1e40af; }
|
|
|
|
.down-time {
|
|
color: var(--danger);
|
|
font-weight: bold;
|
|
}
|
|
|
|
.job-info {
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
/* Loading */
|
|
.loading-spinner {
|
|
display: inline-block;
|
|
width: 18px;
|
|
height: 18px;
|
|
border: 2px solid var(--border);
|
|
border-top-color: var(--primary);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
margin-right: 8px;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.loading-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(255, 255, 255, 0.85);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 10;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.loading-text {
|
|
color: var(--primary);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.placeholder {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 1400px) {
|
|
.kpi-row {
|
|
grid-template-columns: repeat(5, 1fr);
|
|
}
|
|
.workcenter-grid {
|
|
grid-template-columns: repeat(4, 1fr);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 1200px) {
|
|
.kpi-row {
|
|
grid-template-columns: repeat(3, 1fr);
|
|
}
|
|
.workcenter-grid {
|
|
grid-template-columns: repeat(3, 1fr);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.kpi-row {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
.workcenter-grid {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
.header {
|
|
align-items: flex-start;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
</head>
|
|
<body>
|
|
<div class="dashboard">
|
|
<!-- Header -->
|
|
<div class="header">
|
|
<h1>全廠機況 Dashboard</h1>
|
|
<div class="header-right">
|
|
<div class="filters">
|
|
<div class="filter-group">
|
|
<label><input type="checkbox" id="filterProduction"> 生產設備</label>
|
|
<label><input type="checkbox" id="filterKey"> 關鍵設備</label>
|
|
<label><input type="checkbox" id="filterMonitor"> 監控設備</label>
|
|
</div>
|
|
|
|
|
|
<button id="btnQuery" class="btn-query" onclick="loadDashboard()">查詢</button>
|
|
</div>
|
|
<span id="lastUpdate" class="last-update"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- KPI Row: OU / RUN / DOWN / IDLE / ENG -->
|
|
<div class="kpi-row">
|
|
<div class="kpi-card">
|
|
<div class="kpi-label">OU%</div>
|
|
<div class="kpi-value green" id="kpiOu">-</div>
|
|
<div class="kpi-sub" id="kpiOuFormula">PRD / (PRD+SBY+EGT+SDT+UDT)</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-label">RUN (PRD)</div>
|
|
<div class="kpi-value green" id="kpiRun">-</div>
|
|
<div class="kpi-sub" id="kpiRunCount">RUN: -</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-label">DOWN (UDT+SDT)</div>
|
|
<div class="kpi-value red" id="kpiDown">-</div>
|
|
<div class="kpi-sub" id="kpiDownDetail">UDT: - / SDT: -</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-label">IDLE (SBY+NST)</div>
|
|
<div class="kpi-value yellow" id="kpiIdle">-</div>
|
|
<div class="kpi-sub" id="kpiIdleDetail">SBY: - / NST: -</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-label">ENG (EGT)</div>
|
|
<div class="kpi-value blue" id="kpiEng">-</div>
|
|
<div class="kpi-sub" id="kpiEngCount">EGT: -</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Workcenter Cards -->
|
|
<div class="workcenter-section">
|
|
<div class="section-title">各工站狀態 (點選卡片查看明細)</div>
|
|
<div class="workcenter-grid" id="workcenterGrid">
|
|
<div class="placeholder">請點擊「查詢」載入資料</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Detail Table -->
|
|
<div class="detail-section" style="position: relative;">
|
|
<div class="detail-header">
|
|
<div class="detail-title" id="detailTitle">DOWN 機台明細 (UDT/SDT)</div>
|
|
<div class="detail-count" id="detailCount"></div>
|
|
</div>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>機台</th>
|
|
<th>工作中心</th>
|
|
<th>狀態</th>
|
|
<th>原因</th>
|
|
<th>最後狀態時間</th>
|
|
<th>Down Time</th>
|
|
<th>批號 (PJ_LOTID)</th>
|
|
<th>症狀 (JOB)</th>
|
|
<th>原因碼 (JOB)</th>
|
|
<th>標記</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="detailTableBody">
|
|
<tr><td colspan="10" class="placeholder">請點擊「查詢」載入資料</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let selectedWorkcenter = null;
|
|
let selectedOriginalWcs = null; // 儲存合併群組的原始工站列表
|
|
let workcenterData = {}; // 快取工站資料
|
|
let isLoading = false;
|
|
let miniCharts = {}; // 快取 ECharts 實例
|
|
|
|
// 狀態顏色定義 (統一)
|
|
const STATUS_COLORS = {
|
|
PRD: '#00ff88', // 綠色 - 生產中
|
|
SBY: '#17a2b8', // 青色 - 待機
|
|
UDT: '#ff4757', // 紅色 - 非計畫停機
|
|
SDT: '#ffc107', // 黃色 - 計畫停機
|
|
EGT: '#6c757d', // 灰色 - 工程時間
|
|
NST: '#9b59b6' // 紫色 - NST
|
|
};
|
|
|
|
function getFilters() {
|
|
const filters = {};
|
|
if (document.getElementById('filterProduction').checked) filters.isProduction = true;
|
|
if (document.getElementById('filterKey').checked) filters.isKey = true;
|
|
if (document.getElementById('filterMonitor').checked) filters.isMonitor = true;
|
|
filters.days_back = 365;
|
|
|
|
// 多選廠區
|
|
return Object.keys(filters).length > 0 ? filters : null;
|
|
}
|
|
|
|
function formatNumber(num) {
|
|
if (num === null || num === undefined) return '-';
|
|
return num.toLocaleString('zh-TW');
|
|
}
|
|
|
|
function getStatusClass(status) {
|
|
if (!status) return 'status-other';
|
|
const s = status.toUpperCase();
|
|
if (s === 'PRD') return 'status-prd';
|
|
if (s === 'SBY') return 'status-sby';
|
|
if (s === 'UDT') return 'status-udt';
|
|
if (s === 'SDT') return 'status-sdt';
|
|
if (s === 'EGT') return 'status-egt';
|
|
if (s === 'NST') return 'status-nst';
|
|
return 'status-other';
|
|
}
|
|
|
|
function getOuClass(ouPct) {
|
|
if (ouPct >= 80) return 'high';
|
|
if (ouPct >= 50) return 'medium';
|
|
return 'low';
|
|
}
|
|
|
|
function formatDownTime(minutes) {
|
|
if (!minutes || minutes <= 0) return '-';
|
|
if (minutes < 60) return `${Math.round(minutes)}m`;
|
|
const hours = Math.floor(minutes / 60);
|
|
const mins = Math.round(minutes % 60);
|
|
return `${hours}h ${mins}m`;
|
|
}
|
|
|
|
async function loadKPI() {
|
|
try {
|
|
const response = await fetch('/api/dashboard/kpi', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ filters: getFilters() })
|
|
});
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
const d = result.data;
|
|
// 計算各項百分比
|
|
const total = d.total || 1;
|
|
const runPct = ((d.run / total) * 100).toFixed(1);
|
|
const downPct = ((d.down / total) * 100).toFixed(1);
|
|
const idlePct = ((d.idle / total) * 100).toFixed(1);
|
|
const engPct = ((d.eng / total) * 100).toFixed(1);
|
|
|
|
// OU%
|
|
document.getElementById('kpiOu').textContent = d.ou_pct + '%';
|
|
document.getElementById('kpiOuFormula').textContent = `PRD / (PRD+SBY+EGT+SDT+UDT)`;
|
|
// RUN (PRD) - 顯示百分比
|
|
document.getElementById('kpiRun').textContent = runPct + '%';
|
|
document.getElementById('kpiRunCount').textContent = `RUN: ${formatNumber(d.run)}`;
|
|
// DOWN (UDT+SDT) - 顯示百分比
|
|
document.getElementById('kpiDown').textContent = downPct + '%';
|
|
document.getElementById('kpiDownDetail').textContent = `UDT: ${formatNumber(d.udt)} / SDT: ${formatNumber(d.sdt)}`;
|
|
// IDLE (SBY+NST) - 顯示百分比
|
|
document.getElementById('kpiIdle').textContent = idlePct + '%';
|
|
document.getElementById('kpiIdleDetail').textContent = `SBY: ${formatNumber(d.sby)} / NST: ${formatNumber(d.nst)}`;
|
|
// ENG (EGT) - 顯示百分比
|
|
document.getElementById('kpiEng').textContent = engPct + '%';
|
|
document.getElementById('kpiEngCount').textContent = `EGT: ${formatNumber(d.eng)}`;
|
|
}
|
|
} catch (error) {
|
|
console.error('KPI 載入失敗:', error);
|
|
}
|
|
}
|
|
|
|
async function loadWorkcenterCards() {
|
|
const grid = document.getElementById('workcenterGrid');
|
|
grid.innerHTML = '<div class="placeholder"><span class="loading-spinner"></span>載入中...</div>';
|
|
|
|
try {
|
|
const response = await fetch('/api/dashboard/workcenter_cards', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ filters: getFilters() })
|
|
});
|
|
const result = await response.json();
|
|
|
|
if (result.success && result.data.length > 0) {
|
|
// 快取工站資料
|
|
workcenterData = {};
|
|
result.data.forEach(wc => {
|
|
workcenterData[wc.workcenter] = wc;
|
|
});
|
|
|
|
let html = '';
|
|
result.data.forEach(wc => {
|
|
const ouClass = getOuClass(wc.ou_pct);
|
|
const hasIssue = wc.udt > 0 || wc.sdt > 0;
|
|
const selectedClass = selectedWorkcenter === wc.workcenter ? 'selected' : '';
|
|
// 如果有多個原始工站,顯示提示
|
|
const tooltip = wc.original_wcs && wc.original_wcs.length > 1
|
|
? `${wc.workcenter} (含: ${wc.original_wcs.join(', ')})`
|
|
: wc.workcenter;
|
|
|
|
html += `
|
|
<div class="wc-card ${hasIssue ? 'has-issue' : ''} ${selectedClass}"
|
|
onclick="selectWorkcenter('${wc.workcenter}')">
|
|
<div class="wc-name" title="${tooltip}">${wc.workcenter}</div>
|
|
<div class="wc-stats">
|
|
<div class="wc-ou ${ouClass}">${wc.ou_pct}%</div>
|
|
<div class="wc-counts">
|
|
<div>Total: ${wc.total}</div>
|
|
<div>RUN: ${wc.prd}</div>
|
|
${wc.down > 0 ? `<div class="down">Down: ${wc.down}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="wc-mini-chart" id="miniChart_${encodeURIComponent(wc.workcenter)}"></div>
|
|
</div>
|
|
`;
|
|
});
|
|
grid.innerHTML = html;
|
|
|
|
// Render mini charts
|
|
result.data.forEach(wc => {
|
|
renderMiniChart(wc);
|
|
});
|
|
} else {
|
|
grid.innerHTML = '<div class="placeholder">查無資料</div>';
|
|
}
|
|
} catch (error) {
|
|
console.error('工站卡片載入失敗:', error);
|
|
grid.innerHTML = '<div class="placeholder">載入失敗</div>';
|
|
}
|
|
}
|
|
|
|
function renderMiniChart(wc, retryCount = 0) {
|
|
// 使用 encodeURIComponent 確保中文名稱生成唯一 ID
|
|
const chartId = `miniChart_${encodeURIComponent(wc.workcenter)}`;
|
|
const chartDom = document.getElementById(chartId);
|
|
if (!chartDom) {
|
|
console.warn(`圖表容器不存在: ${chartId}`);
|
|
return;
|
|
}
|
|
|
|
// 銷毀舊的實例(如果存在)
|
|
if (miniCharts[chartId]) {
|
|
miniCharts[chartId].dispose();
|
|
}
|
|
|
|
// 確保容器有尺寸,如果沒有則延遲重試
|
|
if (chartDom.offsetWidth === 0 || chartDom.offsetHeight === 0) {
|
|
if (retryCount < 3) {
|
|
setTimeout(() => renderMiniChart(wc, retryCount + 1), 100);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const chart = echarts.init(chartDom);
|
|
miniCharts[chartId] = chart;
|
|
|
|
const total = wc.total || 1;
|
|
|
|
// 計算各狀態百分比
|
|
const calcPct = (val) => ((val / total) * 100).toFixed(1);
|
|
|
|
const option = {
|
|
tooltip: {
|
|
trigger: 'item',
|
|
formatter: function(params) {
|
|
const statusMap = {
|
|
'PRD': '生產中',
|
|
'SBY': '待機',
|
|
'UDT': '非計畫停機',
|
|
'SDT': '計畫停機',
|
|
'EGT': '工程時間',
|
|
'NST': '未排單'
|
|
};
|
|
const statusName = statusMap[params.seriesName] || params.seriesName;
|
|
const count = params.value;
|
|
const pct = calcPct(count);
|
|
return `<div style="font-weight:bold;margin-bottom:5px;">${statusName}</div>
|
|
<div>機台數: ${count}</div>
|
|
<div>佔比: ${pct}%</div>`;
|
|
},
|
|
backgroundColor: 'rgba(22, 33, 62, 0.95)',
|
|
borderColor: '#00d4ff',
|
|
borderWidth: 1,
|
|
textStyle: { color: '#eee', fontSize: 12 },
|
|
padding: [8, 12]
|
|
},
|
|
grid: { left: 0, right: 0, top: 0, bottom: 0 },
|
|
xAxis: { type: 'value', show: false, max: total },
|
|
yAxis: { type: 'category', show: false, data: [''] },
|
|
series: [
|
|
{
|
|
name: 'PRD',
|
|
type: 'bar',
|
|
stack: 'total',
|
|
data: [wc.prd],
|
|
itemStyle: { color: STATUS_COLORS.PRD },
|
|
barWidth: '100%',
|
|
emphasis: { itemStyle: { color: STATUS_COLORS.PRD } }
|
|
},
|
|
{
|
|
name: 'SBY',
|
|
type: 'bar',
|
|
stack: 'total',
|
|
data: [wc.sby],
|
|
itemStyle: { color: STATUS_COLORS.SBY },
|
|
barWidth: '100%',
|
|
emphasis: { itemStyle: { color: STATUS_COLORS.SBY } }
|
|
},
|
|
{
|
|
name: 'UDT',
|
|
type: 'bar',
|
|
stack: 'total',
|
|
data: [wc.udt],
|
|
itemStyle: { color: STATUS_COLORS.UDT },
|
|
barWidth: '100%',
|
|
emphasis: { itemStyle: { color: STATUS_COLORS.UDT } }
|
|
},
|
|
{
|
|
name: 'SDT',
|
|
type: 'bar',
|
|
stack: 'total',
|
|
data: [wc.sdt],
|
|
itemStyle: { color: STATUS_COLORS.SDT },
|
|
barWidth: '100%',
|
|
emphasis: { itemStyle: { color: STATUS_COLORS.SDT } }
|
|
},
|
|
{
|
|
name: 'EGT',
|
|
type: 'bar',
|
|
stack: 'total',
|
|
data: [wc.egt],
|
|
itemStyle: { color: STATUS_COLORS.EGT },
|
|
barWidth: '100%',
|
|
emphasis: { itemStyle: { color: STATUS_COLORS.EGT } }
|
|
},
|
|
{
|
|
name: 'NST',
|
|
type: 'bar',
|
|
stack: 'total',
|
|
data: [wc.nst],
|
|
itemStyle: { color: STATUS_COLORS.NST },
|
|
barWidth: '100%',
|
|
emphasis: { itemStyle: { color: STATUS_COLORS.NST } }
|
|
}
|
|
]
|
|
};
|
|
chart.setOption(option);
|
|
}
|
|
|
|
// 全局 resize 監聽器 - 調整所有圖表大小
|
|
window.addEventListener('resize', () => {
|
|
Object.values(miniCharts).forEach(chart => {
|
|
if (chart && !chart.isDisposed()) {
|
|
chart.resize();
|
|
}
|
|
});
|
|
});
|
|
|
|
function selectWorkcenter(workcenter) {
|
|
if (selectedWorkcenter === workcenter) {
|
|
selectedWorkcenter = null;
|
|
selectedOriginalWcs = null;
|
|
} else {
|
|
selectedWorkcenter = workcenter;
|
|
// 取得合併群組的原始工站列表
|
|
if (workcenterData[workcenter] && workcenterData[workcenter].original_wcs) {
|
|
selectedOriginalWcs = workcenterData[workcenter].original_wcs;
|
|
} else {
|
|
selectedOriginalWcs = [workcenter];
|
|
}
|
|
}
|
|
|
|
// Update card selection
|
|
document.querySelectorAll('.wc-card').forEach(card => {
|
|
card.classList.remove('selected');
|
|
});
|
|
if (selectedWorkcenter) {
|
|
const cards = document.querySelectorAll('.wc-card');
|
|
cards.forEach(card => {
|
|
if (card.querySelector('.wc-name').textContent === selectedWorkcenter) {
|
|
card.classList.add('selected');
|
|
}
|
|
});
|
|
}
|
|
|
|
loadDetail();
|
|
}
|
|
|
|
async function loadDetail() {
|
|
const tbody = document.getElementById('detailTableBody');
|
|
const title = document.getElementById('detailTitle');
|
|
const count = document.getElementById('detailCount');
|
|
|
|
tbody.innerHTML = '<tr><td colspan="10" class="placeholder"><span class="loading-spinner"></span>載入中...</td></tr>';
|
|
|
|
const filters = getFilters() || {};
|
|
if (selectedWorkcenter) {
|
|
filters.workcenter = selectedWorkcenter;
|
|
// 傳遞原始工站列表,讓後端可以用 IN 查詢
|
|
if (selectedOriginalWcs && selectedOriginalWcs.length > 0) {
|
|
filters.original_wcs = selectedOriginalWcs;
|
|
}
|
|
title.textContent = `DOWN 機台明細 - ${selectedWorkcenter}`;
|
|
} else {
|
|
title.textContent = 'DOWN 機台明細 (全部)';
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/dashboard/detail', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ filters: Object.keys(filters).length > 0 ? filters : null, limit: 200, offset: 0 })
|
|
});
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
count.textContent = `(${result.count} 筆)`;
|
|
|
|
// 更新 Last Update 使用 API 返回的最新狀態時間
|
|
if (result.max_status_time) {
|
|
document.getElementById('lastUpdate').textContent = `Last Update: ${result.max_status_time}`;
|
|
}
|
|
|
|
if (result.data.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="10" class="placeholder">查無資料</td></tr>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
result.data.forEach(row => {
|
|
const statusClass = getStatusClass(row.NEWSTATUSNAME);
|
|
const downTime = formatDownTime(row.DOWN_MINUTES);
|
|
|
|
let flags = '';
|
|
if (row.PJ_ISPRODUCTION === 1) flags += '<span class="flag-badge flag-prod">生產</span>';
|
|
if (row.PJ_ISKEY === 1) flags += '<span class="flag-badge flag-key">關鍵</span>';
|
|
if (row.PJ_ISMONITOR === 1) flags += '<span class="flag-badge flag-monitor">監控</span>';
|
|
|
|
// PJ_LOTID 來自 DW_MES_RESOURCE
|
|
// SYMPTOMCODENAME, CAUSECODENAME 來自 DW_MES_JOB (透過 JOBID 關聯)
|
|
// DOWN_MINUTES 使用最新 LASTSTATUSCHANGEDATE - 每台機台自己的時間
|
|
html += `
|
|
<tr>
|
|
<td><strong>${row.RESOURCENAME || '-'}</strong></td>
|
|
<td>${row.WORKCENTERNAME || '-'}</td>
|
|
<td><span class="status-badge ${statusClass}">${row.NEWSTATUSNAME || '-'}</span></td>
|
|
<td>${row.NEWREASONNAME || '-'}</td>
|
|
<td>${row.LASTSTATUSCHANGEDATE || '-'}</td>
|
|
<td class="${row.DOWN_MINUTES > 0 ? 'down-time' : ''}">${downTime}</td>
|
|
<td class="job-info">${row.PJ_LOTID || '-'}</td>
|
|
<td class="job-info">${row.SYMPTOMCODENAME || '-'}</td>
|
|
<td class="job-info">${row.CAUSECODENAME || '-'}</td>
|
|
<td>${flags || '-'}</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
tbody.innerHTML = html;
|
|
} else {
|
|
tbody.innerHTML = `<tr><td colspan="10" class="placeholder">${result.error}</td></tr>`;
|
|
}
|
|
} catch (error) {
|
|
tbody.innerHTML = `<tr><td colspan="10" class="placeholder">載入失敗: ${error.message}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
async function loadDashboard() {
|
|
if (isLoading) return;
|
|
isLoading = true;
|
|
|
|
const btn = document.getElementById('btnQuery');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="loading-spinner"></span>查詢中...';
|
|
|
|
// Last Update 會由 loadDetail() 中的 API 回應更新
|
|
|
|
try {
|
|
await Promise.all([
|
|
loadKPI(),
|
|
loadWorkcenterCards(),
|
|
loadDetail()
|
|
]);
|
|
} finally {
|
|
isLoading = false;
|
|
btn.disabled = false;
|
|
btn.textContent = '查詢';
|
|
}
|
|
}
|
|
|
|
// Page init - 使用標記確保只執行一次
|
|
</script>
|
|
</body>
|
|
</html>
|