feat: 全域連線管理 - 統一 API 客戶端與 Toast 通知系統
實作統一的前端連線管理架構: - 新增 MesApi 客戶端:timeout、exponential backoff retry、AbortController 支援 - 新增 Toast 通知系統:info/success/warning/error/loading 類型 - 新增 _base.html 基礎模板:統一載入核心 JS 模組 - 遷移全部 6 個頁面使用新架構 測試覆蓋: - 17 個單元測試驗證模板整合 - 12 個整合測試驗證 API 格式 - 32 個 E2E 測試 (Playwright) 驗證完整功能 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,651 @@
|
||||
# Technical Design
|
||||
|
||||
## Decision: 前端模組架構
|
||||
|
||||
**選擇**: 獨立 JS 檔案 + Jinja2 Base Template
|
||||
|
||||
**原因**:
|
||||
- 模組化:各頁面共用同一份程式碼,維護一處即可
|
||||
- 強制性:透過 Base Template,新頁面自動繼承核心模組
|
||||
- 現代化:符合業界標準的前端架構模式
|
||||
|
||||
**檔案結構**:
|
||||
```
|
||||
src/mes_dashboard/
|
||||
├── static/
|
||||
│ └── js/
|
||||
│ ├── mes-api.js ← 核心 API Client
|
||||
│ └── toast.js ← 通知系統
|
||||
└── templates/
|
||||
├── _base.html ← Base template (載入核心 JS)
|
||||
├── wip_detail.html ← {% extends "_base.html" %}
|
||||
├── wip_overview.html ← {% extends "_base.html" %}
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decision: API Client 設計 (mes-api.js)
|
||||
|
||||
**選擇**: 全域 `MesApi` 物件,提供統一的 fetch wrapper
|
||||
|
||||
**API 設計**:
|
||||
```javascript
|
||||
// 基本使用
|
||||
const data = await MesApi.get('/api/wip/summary');
|
||||
const data = await MesApi.post('/api/query_table', { table_name: 'xxx' });
|
||||
|
||||
// 帶參數
|
||||
const data = await MesApi.get('/api/wip/detail/ASSY', {
|
||||
params: { page: 1, page_size: 100 }
|
||||
});
|
||||
|
||||
// 可取消的請求
|
||||
const controller = new AbortController();
|
||||
const data = await MesApi.get('/api/xxx', { signal: controller.signal });
|
||||
controller.abort(); // 取消
|
||||
|
||||
// 自訂選項
|
||||
const data = await MesApi.get('/api/xxx', {
|
||||
timeout: 60000, // 覆蓋預設 timeout (30s)
|
||||
retries: 5, // 覆蓋預設重試次數 (3)
|
||||
silent: true, // 不顯示 toast
|
||||
});
|
||||
```
|
||||
|
||||
**內部結構**:
|
||||
```javascript
|
||||
const MesApi = {
|
||||
// 預設配置
|
||||
defaults: {
|
||||
timeout: 30000,
|
||||
retries: 3,
|
||||
retryDelays: [1000, 2000, 4000], // Exponential backoff
|
||||
},
|
||||
|
||||
// GET 請求
|
||||
async get(url, options = {}) { ... },
|
||||
|
||||
// POST 請求
|
||||
async post(url, data, options = {}) { ... },
|
||||
|
||||
// 內部方法
|
||||
_fetch(url, options) { ... },
|
||||
_retry(fn, retries, delays) { ... },
|
||||
_generateRequestId() { ... },
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decision: Retry 策略
|
||||
|
||||
**選擇**: Exponential Backoff(指數退避)
|
||||
|
||||
**來源**: AWS SDK、Google Cloud Client Libraries 標準做法
|
||||
|
||||
**配置**:
|
||||
| 重試次數 | 等待時間 | 累計時間 |
|
||||
|----------|----------|----------|
|
||||
| 1st retry | 1000ms | 1s |
|
||||
| 2nd retry | 2000ms | 3s |
|
||||
| 3rd retry | 4000ms | 7s |
|
||||
| Give up | - | - |
|
||||
|
||||
**重試條件**:
|
||||
| 錯誤類型 | 重試 | 原因 |
|
||||
|----------|------|------|
|
||||
| Timeout | ✓ | 網路慢或 server 忙 |
|
||||
| Network Error | ✓ | 暫時性網路問題 |
|
||||
| 5xx Server Error | ✓ | Server 暫時錯誤 |
|
||||
| 4xx Client Error | ✗ | 參數錯誤,重試無意義 |
|
||||
| Aborted | ✗ | 使用者主動取消 |
|
||||
|
||||
**實作**:
|
||||
```javascript
|
||||
async _fetchWithRetry(url, options, retryCount = 0) {
|
||||
try {
|
||||
return await this._fetch(url, options);
|
||||
} catch (error) {
|
||||
// 不重試的情況
|
||||
if (error.name === 'AbortError') throw error;
|
||||
if (error.status >= 400 && error.status < 500) throw error;
|
||||
|
||||
// 超過重試次數
|
||||
if (retryCount >= this.defaults.retries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 等待後重試
|
||||
const delay = this.defaults.retryDelays[retryCount];
|
||||
Toast.info(`正在重試 (${retryCount + 1}/${this.defaults.retries})...`);
|
||||
await this._sleep(delay);
|
||||
|
||||
return this._fetchWithRetry(url, options, retryCount + 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decision: Toast 通知系統
|
||||
|
||||
**選擇**: 輕量級自建 Toast,不引入外部依賴
|
||||
|
||||
**原因**:
|
||||
- 功能簡單,不需要完整 UI 框架
|
||||
- 減少依賴,避免版本衝突
|
||||
- 完全控制樣式與行為
|
||||
|
||||
**Toast 類型**:
|
||||
| Type | 顏色 | Icon | 自動消失 |
|
||||
|------|------|------|----------|
|
||||
| info | 藍 | ℹ | 3s |
|
||||
| success | 綠 | ✓ | 2s |
|
||||
| warning | 橙 | ⚠ | 5s |
|
||||
| error | 紅 | ✗ | 不消失 |
|
||||
| loading | 灰 | ⟳ | 不消失 |
|
||||
|
||||
**API 設計**:
|
||||
```javascript
|
||||
// 基本使用
|
||||
Toast.info('訊息已發送');
|
||||
Toast.success('資料已更新');
|
||||
Toast.warning('連線不穩定');
|
||||
Toast.error('載入失敗', { retry: () => loadData() });
|
||||
|
||||
// Loading 狀態(可更新/關閉)
|
||||
const id = Toast.loading('載入中...');
|
||||
Toast.update(id, { type: 'success', message: '完成' });
|
||||
// 或
|
||||
Toast.dismiss(id);
|
||||
```
|
||||
|
||||
**位置**: 畫面右上角,堆疊顯示(最新在上)
|
||||
|
||||
---
|
||||
|
||||
## Decision: Request Deduplication
|
||||
|
||||
**選擇**: 可選功能,預設關閉
|
||||
|
||||
**原因**:
|
||||
- 大多數場景不需要(AbortController 已處理)
|
||||
- 某些場景需要重複請求(如強制刷新)
|
||||
- 保持簡單,避免過度設計
|
||||
|
||||
**使用方式**:
|
||||
```javascript
|
||||
// 啟用 deduplication
|
||||
const data = await MesApi.get('/api/xxx', { dedupe: true });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decision: Base Template 設計
|
||||
|
||||
**選擇**: Jinja2 `{% extends %}` + `{% block %}` 模式
|
||||
|
||||
**_base.html 結構**:
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}MES Dashboard{% endblock %}</title>
|
||||
|
||||
<!-- Toast 樣式 -->
|
||||
<style id="mes-core-styles">
|
||||
.mes-toast-container { ... }
|
||||
.mes-toast { ... }
|
||||
</style>
|
||||
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Toast 容器 (所有頁面都有) -->
|
||||
<div id="mes-toast-container"></div>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<!-- 核心 JS (所有頁面必載) -->
|
||||
<script src="{{ url_for('static', filename='js/toast.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/mes-api.js') }}"></script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**子頁面使用**:
|
||||
```html
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% block title %}WIP Detail{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>/* 頁面專屬樣式 */</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- 頁面內容 -->
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// 使用 MesApi
|
||||
async function loadData() {
|
||||
const data = await MesApi.get('/api/wip/summary');
|
||||
// ...
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### mes-api.js 核心邏輯
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* MES Dashboard API Client
|
||||
* 提供統一的 API 請求管理,內建 timeout、retry、取消機制
|
||||
*/
|
||||
const MesApi = (function() {
|
||||
'use strict';
|
||||
|
||||
const defaults = {
|
||||
timeout: 30000, // 30s
|
||||
retries: 3,
|
||||
retryDelays: [1000, 2000, 4000],
|
||||
baseUrl: '',
|
||||
};
|
||||
|
||||
// 請求 ID 計數器
|
||||
let requestCounter = 0;
|
||||
|
||||
function generateRequestId() {
|
||||
return `req_${(++requestCounter).toString(36)}`;
|
||||
}
|
||||
|
||||
function buildUrl(url, params) {
|
||||
if (!params) return url;
|
||||
const searchParams = new URLSearchParams(params);
|
||||
return `${url}?${searchParams}`;
|
||||
}
|
||||
|
||||
function log(requestId, ...args) {
|
||||
console.log(`[MesApi] ${requestId}`, ...args);
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options, timeout, signal) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
// 合併外部 signal
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => controller.abort());
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function request(method, url, data, options = {}) {
|
||||
const requestId = generateRequestId();
|
||||
const timeout = options.timeout ?? defaults.timeout;
|
||||
const retries = options.retries ?? defaults.retries;
|
||||
const silent = options.silent ?? false;
|
||||
|
||||
const fullUrl = buildUrl(defaults.baseUrl + url, options.params);
|
||||
|
||||
log(requestId, method, fullUrl);
|
||||
|
||||
const fetchOptions = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
|
||||
if (data) {
|
||||
fetchOptions.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const response = await fetchWithTimeout(
|
||||
fullUrl, fetchOptions, timeout, options.signal
|
||||
);
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP ${response.status}`);
|
||||
error.status = response.status;
|
||||
error.response = response;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
log(requestId, `✓ ${response.status} (${elapsed}ms)`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// Aborted: 不重試,不顯示 toast
|
||||
if (error.name === 'AbortError') {
|
||||
log(requestId, '⊘ Aborted');
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 4xx: 不重試
|
||||
if (error.status >= 400 && error.status < 500) {
|
||||
log(requestId, `✗ ${error.status} (no retry)`);
|
||||
if (!silent) Toast.error(`請求失敗: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 最後一次嘗試失敗
|
||||
if (attempt === retries) {
|
||||
log(requestId, `✗ Failed after ${retries + 1} attempts`);
|
||||
if (!silent) {
|
||||
Toast.error('連線失敗', {
|
||||
retry: () => request(method, url, data, options)
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 準備重試
|
||||
const delay = defaults.retryDelays[attempt] ?? 4000;
|
||||
log(requestId, `✗ Retry ${attempt + 1}/${retries} in ${delay}ms`);
|
||||
if (!silent) {
|
||||
Toast.info(`正在重試 (${attempt + 1}/${retries})...`);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
return {
|
||||
defaults,
|
||||
get: (url, options) => request('GET', url, null, options),
|
||||
post: (url, data, options) => request('POST', url, data, options),
|
||||
};
|
||||
})();
|
||||
```
|
||||
|
||||
### toast.js 核心邏輯
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* MES Dashboard Toast Notification System
|
||||
*/
|
||||
const Toast = (function() {
|
||||
'use strict';
|
||||
|
||||
const container = document.getElementById('mes-toast-container');
|
||||
let toastId = 0;
|
||||
|
||||
const icons = {
|
||||
info: 'ℹ',
|
||||
success: '✓',
|
||||
warning: '⚠',
|
||||
error: '✗',
|
||||
loading: '⟳',
|
||||
};
|
||||
|
||||
const durations = {
|
||||
info: 3000,
|
||||
success: 2000,
|
||||
warning: 5000,
|
||||
error: 0, // 不自動消失
|
||||
loading: 0, // 不自動消失
|
||||
};
|
||||
|
||||
function create(type, message, options = {}) {
|
||||
const id = `toast-${++toastId}`;
|
||||
const toast = document.createElement('div');
|
||||
toast.id = id;
|
||||
toast.className = `mes-toast mes-toast-${type}`;
|
||||
|
||||
toast.innerHTML = `
|
||||
<span class="mes-toast-icon">${icons[type]}</span>
|
||||
<span class="mes-toast-message">${message}</span>
|
||||
${options.retry ? '<button class="mes-toast-retry">重試</button>' : ''}
|
||||
<button class="mes-toast-close">×</button>
|
||||
`;
|
||||
|
||||
// 事件綁定
|
||||
toast.querySelector('.mes-toast-close').onclick = () => dismiss(id);
|
||||
if (options.retry) {
|
||||
toast.querySelector('.mes-toast-retry').onclick = () => {
|
||||
dismiss(id);
|
||||
options.retry();
|
||||
};
|
||||
}
|
||||
|
||||
container.prepend(toast);
|
||||
|
||||
// 自動消失
|
||||
const duration = options.duration ?? durations[type];
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismiss(id), duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function dismiss(id) {
|
||||
const toast = document.getElementById(id);
|
||||
if (toast) {
|
||||
toast.classList.add('mes-toast-exit');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
function update(id, options) {
|
||||
const toast = document.getElementById(id);
|
||||
if (toast && options.type) {
|
||||
toast.className = `mes-toast mes-toast-${options.type}`;
|
||||
toast.querySelector('.mes-toast-icon').textContent = icons[options.type];
|
||||
}
|
||||
if (toast && options.message) {
|
||||
toast.querySelector('.mes-toast-message').textContent = options.message;
|
||||
}
|
||||
if (options.autoDismiss) {
|
||||
setTimeout(() => dismiss(id), options.autoDismiss);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
info: (msg, opts) => create('info', msg, opts),
|
||||
success: (msg, opts) => create('success', msg, opts),
|
||||
warning: (msg, opts) => create('warning', msg, opts),
|
||||
error: (msg, opts) => create('error', msg, opts),
|
||||
loading: (msg, opts) => create('loading', msg, opts),
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
})();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Request Lifecycle │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Page Code │
|
||||
│ const data = await MesApi.get('/api/wip/summary'); │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ MesApi.request() │ │
|
||||
│ │ 1. Generate Request ID (req_1a2b3c) │ │
|
||||
│ │ 2. Build full URL with params │ │
|
||||
│ │ 3. Log: [MesApi] req_1a2b3c GET /api/wip/summary │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Attempt 1 │ │
|
||||
│ │ - Start timeout timer (30s) │ │
|
||||
│ │ - fetch() with AbortController │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ├── Success ──► Log: ✓ 200 (234ms) ──► Return data │
|
||||
│ │ │
|
||||
│ ├── Timeout/5xx ──► Log: ✗ Retry 1/3 in 1000ms │
|
||||
│ │ Toast.info("正在重試 (1/3)...") │
|
||||
│ │ wait(1000ms) │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ │ Attempt 2 │ │
|
||||
│ │ │ - fetch() again │ │
|
||||
│ │ └─────────────────────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ ├── Success ──► Return data │
|
||||
│ │ ├── Failed ──► Retry 2/3 in 2000ms ... (repeat) │
|
||||
│ │ └── All retries exhausted │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ Toast.error("連線失敗", { retry: fn }) │
|
||||
│ │ throw Error │
|
||||
│ │ │
|
||||
│ ├── 4xx ──► Log: ✗ 400 (no retry) │
|
||||
│ │ Toast.error("請求失敗: ...") │
|
||||
│ │ throw Error │
|
||||
│ │ │
|
||||
│ └── Aborted ──► Log: ⊘ Aborted │
|
||||
│ throw AbortError (no toast) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### 1. 手動測試場景
|
||||
|
||||
| 場景 | 測試方法 | 預期結果 |
|
||||
|------|----------|----------|
|
||||
| 正常請求 | 正常操作 | 無 toast,console 顯示 ✓ |
|
||||
| Timeout | 後端加 `time.sleep(35)` | 顯示重試 toast,最終失敗 |
|
||||
| 5xx 錯誤 | 後端回傳 500 | 顯示重試 toast |
|
||||
| 4xx 錯誤 | 錯誤參數 | 直接顯示錯誤,無重試 |
|
||||
| 請求取消 | 快速換頁 | 無 toast,console 顯示 ⊘ |
|
||||
| 手動重試 | 點擊重試按鈕 | 重新發送請求 |
|
||||
|
||||
### 2. Console 驗證
|
||||
|
||||
```
|
||||
[MesApi] req_1 GET /api/wip/summary
|
||||
[MesApi] req_1 ✓ 200 (234ms)
|
||||
|
||||
[MesApi] req_2 GET /api/wip/detail/ASSY?page=1
|
||||
[MesApi] req_2 ⊘ Aborted
|
||||
|
||||
[MesApi] req_3 GET /api/wip/detail/ASSY?page=2
|
||||
[MesApi] req_3 ✗ Retry 1/3 in 1000ms
|
||||
[MesApi] req_3 ✗ Retry 2/3 in 2000ms
|
||||
[MesApi] req_3 ✓ 200 (1523ms)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: 建立基礎設施
|
||||
1. 建立 `static/js/` 目錄
|
||||
2. 建立 `toast.js`
|
||||
3. 建立 `mes-api.js`
|
||||
4. 建立 `_base.html`
|
||||
|
||||
### Phase 2: 遷移 WIP 頁面
|
||||
1. `wip_detail.html` - 移除 fetchWithTimeout,使用 MesApi
|
||||
2. `wip_overview.html` - 移除 fetchWithTimeout,使用 MesApi
|
||||
3. 測試驗證
|
||||
|
||||
### Phase 3: 遷移其他頁面
|
||||
1. `index.html` (Tables)
|
||||
2. `resource_status.html`
|
||||
3. `excel_query.html`
|
||||
4. `portal.html`
|
||||
|
||||
### 遷移檢查清單(每頁面)
|
||||
|
||||
- [ ] 改用 `{% extends "_base.html" %}`
|
||||
- [ ] 移除內嵌的 `fetchWithTimeout` 函數
|
||||
- [ ] 移除內嵌的 `AbortController` 管理邏輯
|
||||
- [ ] 將 `fetch()` 改為 `MesApi.get()` / `MesApi.post()`
|
||||
- [ ] 保留 AbortController 用於請求取消(傳入 signal 參數)
|
||||
- [ ] 移除手動的錯誤 toast 顯示(MesApi 自動處理)
|
||||
- [ ] 測試正常流程
|
||||
- [ ] 測試錯誤流程
|
||||
|
||||
---
|
||||
|
||||
## 風險與緩解
|
||||
|
||||
| 風險 | 影響 | 緩解措施 |
|
||||
|------|------|----------|
|
||||
| JS 載入失敗 | 頁面無法運作 | 使用 `defer` 確保 DOM 就緒,加入基本 fallback |
|
||||
| Toast 樣式衝突 | 顯示異常 | 使用 `mes-` 前綴的 class 名稱 |
|
||||
| 重試風暴 | Server 壓力增加 | Exponential backoff 已內建延遲 |
|
||||
| 遷移遺漏 | 部分頁面未保護 | 逐頁遷移,完成一個測試一個 |
|
||||
|
||||
---
|
||||
|
||||
## 額外考量
|
||||
|
||||
### URL for Static Files
|
||||
|
||||
確保 Flask 正確服務 static 檔案:
|
||||
|
||||
```python
|
||||
# app.py - Flask 預設已支援,確認 static_folder 設定
|
||||
app = Flask(__name__,
|
||||
template_folder="templates",
|
||||
static_folder="static" # 確保有這個
|
||||
)
|
||||
```
|
||||
|
||||
### Cache Busting(可選)
|
||||
|
||||
防止瀏覽器快取舊版 JS:
|
||||
|
||||
```html
|
||||
<!-- _base.html -->
|
||||
<script src="{{ url_for('static', filename='js/mes-api.js') }}?v=1.0"></script>
|
||||
```
|
||||
|
||||
或使用檔案 hash(進階,暫不實作)。
|
||||
|
||||
### CSP 考量
|
||||
|
||||
如果未來加入 Content Security Policy,確保允許 inline styles(Toast 需要):
|
||||
|
||||
```html
|
||||
<meta http-equiv="Content-Security-Policy" content="style-src 'self' 'unsafe-inline';">
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
## Why
|
||||
|
||||
MES Dashboard 多次遇到 Worker Timeout 和前端請求失敗問題。目前的修復屬於「打補丁」形式,各頁面獨立實作錯誤處理,導致:
|
||||
|
||||
1. **不一致**:WIP 頁面有 `fetchWithTimeout` + `AbortController`,Tables 頁面沒有
|
||||
2. **脆弱**:新功能容易忘記加入連線保護
|
||||
3. **難維護**:錯誤處理邏輯分散在多個 HTML 檔案中
|
||||
|
||||
需要建立全局連線管理機制,讓所有頁面強制使用統一的 API Client,避免類似問題再次發生。
|
||||
|
||||
## What Changes
|
||||
|
||||
### 前端模組化
|
||||
- 建立 `static/js/` 目錄,將共用 JavaScript 模組化
|
||||
- 建立 `mes-api.js`:統一的 API Client,內建 timeout、retry、取消機制
|
||||
- 建立 `toast.js`:統一的通知系統,顯示重試狀態與錯誤訊息
|
||||
- 建立 `_base.html`:Base template,強制所有頁面載入核心模組
|
||||
|
||||
### 錯誤處理標準化
|
||||
- 所有 API 請求必須透過 `MesApi.get()` / `MesApi.post()`
|
||||
- 內建 Exponential Backoff Retry(1s → 2s → 4s,共 3 次)
|
||||
- 自動顯示重試狀態(Toast 通知)
|
||||
- 重試失敗後顯示手動重試按鈕
|
||||
- 所有請求事件記錄到 console(含 request ID)
|
||||
|
||||
### 頁面遷移
|
||||
- 所有現有頁面改用 `{% extends "_base.html" %}`
|
||||
- 移除各頁面內嵌的 `fetchWithTimeout` 實作
|
||||
- 統一使用 `MesApi` 進行 API 呼叫
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `mes-api-client`: 統一的前端 API Client 模組,提供 timeout、retry、cancellation、deduplication 功能
|
||||
- `toast-notification`: 前端通知系統,顯示 info/success/warning/error/loading 狀態
|
||||
- `base-template`: Flask Jinja2 base template,強制載入核心 JS 模組
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `wip-detail`: 遷移至使用 MesApi,移除內嵌 fetchWithTimeout
|
||||
- `wip-overview`: 遷移至使用 MesApi,移除內嵌 fetchWithTimeout
|
||||
- `tables-page`: 加入錯誤處理,使用 MesApi
|
||||
|
||||
## Impact
|
||||
|
||||
- **新增檔案**:
|
||||
- `src/mes_dashboard/static/js/mes-api.js`
|
||||
- `src/mes_dashboard/static/js/toast.js`
|
||||
- `src/mes_dashboard/templates/_base.html`
|
||||
|
||||
- **修改檔案**:
|
||||
- `src/mes_dashboard/templates/wip_detail.html` - 繼承 base,使用 MesApi
|
||||
- `src/mes_dashboard/templates/wip_overview.html` - 繼承 base,使用 MesApi
|
||||
- `src/mes_dashboard/templates/index.html` - 繼承 base,使用 MesApi
|
||||
- `src/mes_dashboard/templates/portal.html` - 繼承 base
|
||||
- `src/mes_dashboard/templates/resource_status.html` - 繼承 base,使用 MesApi
|
||||
- `src/mes_dashboard/templates/excel_query.html` - 繼承 base,使用 MesApi
|
||||
|
||||
- **後端無變更**:此變更純前端,後端 API 保持不變
|
||||
@@ -0,0 +1,206 @@
|
||||
# base-template
|
||||
|
||||
Flask Jinja2 base template,強制所有頁面載入核心 JS 模組。
|
||||
|
||||
## Requirements
|
||||
|
||||
### 目的
|
||||
|
||||
透過 Jinja2 template 繼承機制,確保:
|
||||
1. 所有頁面自動載入 `toast.js` 和 `mes-api.js`
|
||||
2. 所有頁面都有 Toast 容器 (`#mes-toast-container`)
|
||||
3. 新開發的頁面只要繼承 `_base.html`,就自動獲得連線管理功能
|
||||
|
||||
### Template 結構
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}MES Dashboard{% endblock %}</title>
|
||||
|
||||
<!-- Toast 樣式 (內嵌) -->
|
||||
<style id="mes-core-styles">
|
||||
/* Toast CSS */
|
||||
</style>
|
||||
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Toast 容器 -->
|
||||
<div id="mes-toast-container"></div>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<!-- 核心 JS -->
|
||||
<script src="{{ url_for('static', filename='js/toast.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/mes-api.js') }}"></script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Block 定義
|
||||
|
||||
| Block | 用途 | 必填 |
|
||||
|-------|------|------|
|
||||
| `title` | 頁面標題 | 否 (預設 "MES Dashboard") |
|
||||
| `head_extra` | 頁面專屬 CSS / meta tags | 否 |
|
||||
| `content` | 頁面主要內容 | 是 |
|
||||
| `scripts` | 頁面專屬 JavaScript | 否 |
|
||||
|
||||
### 子頁面使用範例
|
||||
|
||||
```html
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% block title %}WIP Detail Dashboard{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.dashboard { max-width: 1900px; }
|
||||
/* 頁面專屬樣式 */
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
<!-- 頁面內容 -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// MesApi 和 Toast 已可用
|
||||
async function loadData() {
|
||||
const data = await MesApi.get('/api/wip/summary');
|
||||
// ...
|
||||
}
|
||||
|
||||
loadData();
|
||||
</script>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
### Toast CSS 樣式規格
|
||||
|
||||
內嵌在 `_base.html` 的 Toast 樣式:
|
||||
|
||||
```css
|
||||
/* 容器 */
|
||||
.mes-toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 單個 Toast */
|
||||
.mes-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-size: 14px;
|
||||
pointer-events: auto;
|
||||
animation: mes-toast-enter 0.3s ease-out;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
/* 類型顏色 */
|
||||
.mes-toast-info { border-left: 4px solid #3b82f6; }
|
||||
.mes-toast-success { border-left: 4px solid #22c55e; }
|
||||
.mes-toast-warning { border-left: 4px solid #f59e0b; }
|
||||
.mes-toast-error { border-left: 4px solid #ef4444; }
|
||||
.mes-toast-loading { border-left: 4px solid #6b7280; }
|
||||
|
||||
/* Icon 顏色 */
|
||||
.mes-toast-info .mes-toast-icon { color: #3b82f6; }
|
||||
.mes-toast-success .mes-toast-icon { color: #22c55e; }
|
||||
.mes-toast-warning .mes-toast-icon { color: #f59e0b; }
|
||||
.mes-toast-error .mes-toast-icon { color: #ef4444; }
|
||||
.mes-toast-loading .mes-toast-icon { color: #6b7280; }
|
||||
|
||||
/* Loading 旋轉動畫 */
|
||||
.mes-toast-loading .mes-toast-icon {
|
||||
animation: mes-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 按鈕 */
|
||||
.mes-toast-close, .mes-toast-retry {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mes-toast-close:hover { background: rgba(0,0,0,0.1); }
|
||||
.mes-toast-retry {
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mes-toast-retry:hover { background: rgba(59,130,246,0.1); }
|
||||
|
||||
/* 動畫 */
|
||||
@keyframes mes-toast-enter {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.mes-toast-exit {
|
||||
animation: mes-toast-exit 0.3s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes mes-toast-exit {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes mes-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
### 需遷移的頁面
|
||||
|
||||
以下頁面需改為繼承 `_base.html`:
|
||||
|
||||
| 頁面 | 檔案 | 備註 |
|
||||
|------|------|------|
|
||||
| Portal | `portal.html` | 首頁,可能不需要 API |
|
||||
| WIP Detail | `wip_detail.html` | 需移除內嵌 fetchWithTimeout |
|
||||
| WIP Overview | `wip_overview.html` | 需移除內嵌 fetchWithTimeout |
|
||||
| Tables | `index.html` | 需加入 MesApi 使用 |
|
||||
| Resource | `resource_status.html` | 需加入 MesApi 使用 |
|
||||
| Excel Query | `excel_query.html` | 需加入 MesApi 使用 |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `_base.html` 包含所有必要的 block 定義
|
||||
- [ ] Toast 容器 `#mes-toast-container` 存在於 body 內
|
||||
- [ ] `toast.js` 和 `mes-api.js` 正確載入
|
||||
- [ ] Toast CSS 樣式正確內嵌
|
||||
- [ ] 子頁面可透過 `{% extends "_base.html" %}` 繼承
|
||||
- [ ] 子頁面可使用 `MesApi` 和 `Toast` 全域物件
|
||||
- [ ] 所有 6 個現有頁面成功遷移
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `toast.js` (toast-notification capability)
|
||||
- `mes-api.js` (mes-api-client capability)
|
||||
|
||||
## File Location
|
||||
|
||||
`src/mes_dashboard/templates/_base.html`
|
||||
@@ -0,0 +1,82 @@
|
||||
# mes-api-client
|
||||
|
||||
統一的前端 API Client 模組,提供 timeout、retry、cancellation 功能。
|
||||
|
||||
## Requirements
|
||||
|
||||
### 核心功能
|
||||
|
||||
1. **統一請求入口**
|
||||
- 提供 `MesApi.get(url, options)` 用於 GET 請求
|
||||
- 提供 `MesApi.post(url, data, options)` 用於 POST 請求
|
||||
- 所有頁面必須透過此模組發送 API 請求
|
||||
|
||||
2. **Timeout 處理**
|
||||
- 預設 timeout: 30 秒
|
||||
- 可透過 `options.timeout` 覆蓋
|
||||
- Timeout 時自動觸發重試機制
|
||||
|
||||
3. **Exponential Backoff Retry**
|
||||
- 預設重試 3 次
|
||||
- 重試間隔: 1s → 2s → 4s (exponential backoff)
|
||||
- 可透過 `options.retries` 覆蓋重試次數
|
||||
- 重試時顯示 Toast 通知
|
||||
|
||||
4. **請求取消 (Cancellation)**
|
||||
- 支援傳入 `options.signal` (AbortController.signal)
|
||||
- 取消的請求不觸發重試,不顯示錯誤 Toast
|
||||
- Console 記錄 `⊘ Aborted`
|
||||
|
||||
5. **Request ID 追蹤**
|
||||
- 每個請求自動生成唯一 ID (如 `req_1a2b3c`)
|
||||
- Console log 包含 request ID,便於追蹤
|
||||
|
||||
### 錯誤處理
|
||||
|
||||
| 錯誤類型 | 重試 | Toast | 說明 |
|
||||
|----------|------|-------|------|
|
||||
| Timeout | ✓ | 顯示重試狀態 | 網路慢或 server 忙 |
|
||||
| Network Error | ✓ | 顯示重試狀態 | fetch 失敗 |
|
||||
| 5xx Server Error | ✓ | 顯示重試狀態 | Server 暫時錯誤 |
|
||||
| 4xx Client Error | ✗ | 顯示錯誤 | 參數錯誤 |
|
||||
| Aborted | ✗ | 無 | 使用者/程式取消 |
|
||||
|
||||
### Options 參數
|
||||
|
||||
```javascript
|
||||
{
|
||||
params: Object, // URL query parameters
|
||||
timeout: Number, // 覆蓋預設 timeout (ms)
|
||||
retries: Number, // 覆蓋預設重試次數
|
||||
signal: AbortSignal, // 用於取消請求
|
||||
silent: Boolean, // true = 不顯示 Toast
|
||||
}
|
||||
```
|
||||
|
||||
### Console Logging 格式
|
||||
|
||||
```
|
||||
[MesApi] req_xxx GET /api/path
|
||||
[MesApi] req_xxx ✓ 200 (234ms)
|
||||
[MesApi] req_xxx ✗ Retry 1/3 in 1000ms
|
||||
[MesApi] req_xxx ⊘ Aborted
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `MesApi.get()` 可正常發送 GET 請求並返回 JSON
|
||||
- [ ] `MesApi.post()` 可正常發送 POST 請求並返回 JSON
|
||||
- [ ] Timeout 超過時自動重試,顯示 "正在重試 (N/3)..." Toast
|
||||
- [ ] 重試 3 次後失敗,顯示錯誤 Toast 附帶重試按鈕
|
||||
- [ ] 傳入 signal 並 abort 時,請求被取消,無錯誤 Toast
|
||||
- [ ] 4xx 錯誤不重試,直接顯示錯誤
|
||||
- [ ] 所有請求在 console 有 log,包含 request ID
|
||||
- [ ] `options.silent = true` 時不顯示任何 Toast
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `toast.js` (toast-notification capability)
|
||||
|
||||
## File Location
|
||||
|
||||
`src/mes_dashboard/static/js/mes-api.js`
|
||||
@@ -0,0 +1,105 @@
|
||||
# toast-notification
|
||||
|
||||
前端通知系統,顯示 info/success/warning/error/loading 狀態訊息。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Toast 類型
|
||||
|
||||
| Type | 顏色 | Icon | 自動消失 | 用途 |
|
||||
|------|------|------|----------|------|
|
||||
| info | 藍色 (#3b82f6) | ℹ | 3 秒 | 一般資訊 |
|
||||
| success | 綠色 (#22c55e) | ✓ | 2 秒 | 操作成功 |
|
||||
| warning | 橙色 (#f59e0b) | ⚠ | 5 秒 | 警告訊息 |
|
||||
| error | 紅色 (#ef4444) | ✗ | 不自動消失 | 錯誤訊息 |
|
||||
| loading | 灰色 (#6b7280) | ⟳ (動畫) | 不自動消失 | 載入中 |
|
||||
|
||||
### API 設計
|
||||
|
||||
```javascript
|
||||
// 基本使用
|
||||
Toast.info('訊息內容');
|
||||
Toast.success('操作成功');
|
||||
Toast.warning('請注意');
|
||||
Toast.error('發生錯誤');
|
||||
|
||||
// Error 附帶重試按鈕
|
||||
Toast.error('連線失敗', { retry: () => loadData() });
|
||||
|
||||
// Loading 狀態
|
||||
const id = Toast.loading('載入中...');
|
||||
|
||||
// 更新 Toast
|
||||
Toast.update(id, { type: 'success', message: '完成!' });
|
||||
|
||||
// 手動關閉
|
||||
Toast.dismiss(id);
|
||||
```
|
||||
|
||||
### 顯示位置與行為
|
||||
|
||||
1. **位置**: 畫面右上角,距離頂部 20px,距離右側 20px
|
||||
2. **堆疊**: 多個 Toast 垂直堆疊,最新的在最上方
|
||||
3. **最大數量**: 同時最多顯示 5 個,超過時移除最舊的
|
||||
4. **動畫**:
|
||||
- 進入: 從右側滑入 (0.3s ease-out)
|
||||
- 離開: 向右滑出並淡出 (0.3s ease-in)
|
||||
|
||||
### UI 元素
|
||||
|
||||
每個 Toast 包含:
|
||||
1. **Icon**: 對應類型的圖示
|
||||
2. **Message**: 訊息文字
|
||||
3. **Close Button**: 右側 × 按鈕,點擊關閉
|
||||
4. **Retry Button** (可選): 僅 error 類型,點擊觸發 retry callback
|
||||
|
||||
### CSS Class 命名
|
||||
|
||||
使用 `mes-` 前綴避免衝突:
|
||||
- `.mes-toast-container` - 容器
|
||||
- `.mes-toast` - 單個 Toast
|
||||
- `.mes-toast-info` / `.mes-toast-success` / ... - 類型樣式
|
||||
- `.mes-toast-icon` - 圖示
|
||||
- `.mes-toast-message` - 訊息
|
||||
- `.mes-toast-close` - 關閉按鈕
|
||||
- `.mes-toast-retry` - 重試按鈕
|
||||
- `.mes-toast-exit` - 離開動畫
|
||||
|
||||
### Loading Icon 動畫
|
||||
|
||||
```css
|
||||
.mes-toast-loading .mes-toast-icon {
|
||||
animation: mes-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes mes-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `Toast.info()` 顯示藍色 Toast,3 秒後自動消失
|
||||
- [ ] `Toast.success()` 顯示綠色 Toast,2 秒後自動消失
|
||||
- [ ] `Toast.warning()` 顯示橙色 Toast,5 秒後自動消失
|
||||
- [ ] `Toast.error()` 顯示紅色 Toast,不自動消失
|
||||
- [ ] `Toast.error(msg, { retry: fn })` 顯示重試按鈕,點擊觸發 fn
|
||||
- [ ] `Toast.loading()` 顯示帶旋轉動畫的 Toast
|
||||
- [ ] `Toast.update()` 可更新現有 Toast 的類型和訊息
|
||||
- [ ] `Toast.dismiss()` 可手動關閉 Toast
|
||||
- [ ] 點擊 × 按鈕可關閉 Toast
|
||||
- [ ] Toast 有進入/離開動畫
|
||||
- [ ] 同時最多顯示 5 個 Toast
|
||||
|
||||
## Dependencies
|
||||
|
||||
無(獨立模組)
|
||||
|
||||
## File Location
|
||||
|
||||
`src/mes_dashboard/static/js/toast.js`
|
||||
|
||||
## 樣式內嵌於 `_base.html`
|
||||
|
||||
Toast 的 CSS 樣式將內嵌在 `_base.html` 的 `<style>` 區塊中,確保所有頁面都有樣式定義。
|
||||
@@ -0,0 +1,135 @@
|
||||
# Implementation Tasks
|
||||
|
||||
## Phase 1: 建立基礎設施
|
||||
|
||||
### 1.1 建立目錄結構
|
||||
- [x] 建立 `src/mes_dashboard/static/` 目錄
|
||||
- [x] 建立 `src/mes_dashboard/static/js/` 目錄
|
||||
|
||||
### 1.2 建立 toast.js
|
||||
- [x] 實作 Toast 物件,包含 info/success/warning/error/loading 方法
|
||||
- [x] 實作 Toast.update() 方法
|
||||
- [x] 實作 Toast.dismiss() 方法
|
||||
- [x] 實作最大 5 個 Toast 限制
|
||||
- [x] 實作進入/離開動畫
|
||||
|
||||
### 1.3 建立 mes-api.js
|
||||
- [x] 實作 MesApi.get() 方法
|
||||
- [x] 實作 MesApi.post() 方法
|
||||
- [x] 實作 timeout 處理 (預設 30s)
|
||||
- [x] 實作 exponential backoff retry (1s → 2s → 4s)
|
||||
- [x] 實作 AbortController signal 支援
|
||||
- [x] 實作 request ID 生成與 console logging
|
||||
- [x] 整合 Toast 通知(重試中、失敗)
|
||||
|
||||
### 1.4 建立 _base.html
|
||||
- [x] 建立 base template 結構
|
||||
- [x] 定義 title / head_extra / content / scripts blocks
|
||||
- [x] 內嵌 Toast CSS 樣式
|
||||
- [x] 引入 toast.js 和 mes-api.js
|
||||
- [x] 加入 Toast 容器 `#mes-toast-container`
|
||||
|
||||
### 1.5 驗證基礎設施
|
||||
- [x] 確認 Flask 正確服務 static 檔案
|
||||
- [x] 建立自動化測試驗證 MesApi 和 Toast 運作
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 遷移 WIP 頁面
|
||||
|
||||
### 2.1 遷移 wip_detail.html
|
||||
- [x] 改用 `{% extends "_base.html" %}`
|
||||
- [x] 移除內嵌的 `fetchWithTimeout` 函數
|
||||
- [x] 移除內嵌的 AbortController 管理變數
|
||||
- [x] 將 `fetch()` 呼叫改為 `MesApi.get()`
|
||||
- [x] 保留 AbortController 用於請求取消(傳入 signal)
|
||||
- [x] 移除手動的錯誤 toast 顯示邏輯
|
||||
- [x] 測試:E2E 測試驗證頁面載入、Toast 和 MesApi 功能
|
||||
|
||||
### 2.2 遷移 wip_overview.html
|
||||
- [x] 改用 `{% extends "_base.html" %}`
|
||||
- [x] 移除內嵌的 `fetchWithTimeout` 函數
|
||||
- [x] 移除內嵌的 AbortController 管理變數
|
||||
- [x] 將 `fetch()` 呼叫改為 `MesApi.get()`
|
||||
- [x] 保留 AbortController 用於請求取消(傳入 signal)
|
||||
- [x] 移除手動的錯誤 toast 顯示邏輯
|
||||
- [x] 測試:E2E 測試驗證頁面載入、Toast 和 MesApi 功能
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 遷移其他頁面
|
||||
|
||||
### 3.1 遷移 index.html (Tables)
|
||||
- [x] 改用 `{% extends "_base.html" %}`
|
||||
- [x] 將 `fetch()` 呼叫改為 `MesApi.post()`
|
||||
- [x] 加入錯誤處理(之前沒有)
|
||||
- [x] 測試:E2E 測試驗證頁面載入、Toast 和 MesApi 功能
|
||||
|
||||
### 3.2 遷移 resource_status.html
|
||||
- [x] 改用 `{% extends "_base.html" %}`
|
||||
- [x] 將 `fetch()` 呼叫改為 `MesApi.get()` / `MesApi.post()`
|
||||
- [x] 測試:E2E 測試驗證頁面載入、Toast 和 MesApi 功能
|
||||
|
||||
### 3.3 遷移 excel_query.html
|
||||
- [x] 改用 `{% extends "_base.html" %}`
|
||||
- [x] 將 `fetch()` 呼叫改為 `MesApi.post()`
|
||||
- [x] 保留 native fetch 用於 FormData 上傳和 blob 下載
|
||||
- [x] 測試:E2E 測試驗證頁面載入、Toast 和 MesApi 功能
|
||||
|
||||
### 3.4 遷移 portal.html
|
||||
- [x] 改用 `{% extends "_base.html" %}`
|
||||
- [x] 確認 iframe 載入不受影響
|
||||
- [x] 測試:E2E 測試驗證 Tab 切換
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 最終驗證
|
||||
|
||||
### 4.1 整合測試
|
||||
- [x] 測試所有頁面正常運作(61 個自動化測試通過)
|
||||
- [x] E2E 測試驗證 Toast 通知系統
|
||||
- [x] E2E 測試驗證 MesApi 客戶端
|
||||
|
||||
### 4.2 Console Log 驗證
|
||||
- [x] 確認所有請求有 `[MesApi] req_xxx` log(E2E 測試驗證)
|
||||
- [x] 確認成功顯示 `✓`,重試顯示 `✗ Retry`,取消顯示 `⊘`
|
||||
|
||||
### 4.3 清理
|
||||
- [x] 移除任何遺留的舊 fetchWithTimeout 程式碼
|
||||
- [x] 確認沒有直接使用 `fetch()` 的 API 呼叫(除非有特殊原因)
|
||||
|
||||
---
|
||||
|
||||
## 測試覆蓋
|
||||
|
||||
### 單元測試 (17 tests)
|
||||
- tests/test_template_integration.py
|
||||
- 驗證所有頁面引入 toast.js 和 mes-api.js
|
||||
- 驗證 Toast CSS 樣式已內嵌
|
||||
- 驗證 MesApi 使用於各頁面
|
||||
- 驗證靜態檔案正確服務
|
||||
|
||||
### 整合測試 (12 tests)
|
||||
- tests/test_api_integration.py
|
||||
- 驗證 API 回應格式
|
||||
- 驗證錯誤處理
|
||||
- 驗證 Content-Type
|
||||
|
||||
### E2E 測試 (32 tests)
|
||||
- tests/e2e/test_global_connection.py
|
||||
- Portal 頁面載入和 Tab 切換
|
||||
- Toast 通知系統 (info/success/error/loading/dismiss/max limit)
|
||||
- MesApi 客戶端 (get/post/logging)
|
||||
- 所有 6 個頁面載入並包含 Toast 和 MesApi
|
||||
- Console log 驗證 request ID
|
||||
|
||||
---
|
||||
|
||||
## 完成標準
|
||||
|
||||
- [x] 所有 6 個頁面使用 `{% extends "_base.html" %}`
|
||||
- [x] 所有 API 請求透過 `MesApi` 發送
|
||||
- [x] 錯誤時自動重試並顯示 Toast
|
||||
- [x] 重試失敗時顯示手動重試按鈕
|
||||
- [x] Console 有完整的請求追蹤 log
|
||||
- [x] 61 個自動化測試全部通過
|
||||
206
openspec/specs/base-template/spec.md
Normal file
206
openspec/specs/base-template/spec.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# base-template
|
||||
|
||||
Flask Jinja2 base template,強制所有頁面載入核心 JS 模組。
|
||||
|
||||
## Requirements
|
||||
|
||||
### 目的
|
||||
|
||||
透過 Jinja2 template 繼承機制,確保:
|
||||
1. 所有頁面自動載入 `toast.js` 和 `mes-api.js`
|
||||
2. 所有頁面都有 Toast 容器 (`#mes-toast-container`)
|
||||
3. 新開發的頁面只要繼承 `_base.html`,就自動獲得連線管理功能
|
||||
|
||||
### Template 結構
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}MES Dashboard{% endblock %}</title>
|
||||
|
||||
<!-- Toast 樣式 (內嵌) -->
|
||||
<style id="mes-core-styles">
|
||||
/* Toast CSS */
|
||||
</style>
|
||||
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Toast 容器 -->
|
||||
<div id="mes-toast-container"></div>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<!-- 核心 JS -->
|
||||
<script src="{{ url_for('static', filename='js/toast.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/mes-api.js') }}"></script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Block 定義
|
||||
|
||||
| Block | 用途 | 必填 |
|
||||
|-------|------|------|
|
||||
| `title` | 頁面標題 | 否 (預設 "MES Dashboard") |
|
||||
| `head_extra` | 頁面專屬 CSS / meta tags | 否 |
|
||||
| `content` | 頁面主要內容 | 是 |
|
||||
| `scripts` | 頁面專屬 JavaScript | 否 |
|
||||
|
||||
### 子頁面使用範例
|
||||
|
||||
```html
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% block title %}WIP Detail Dashboard{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.dashboard { max-width: 1900px; }
|
||||
/* 頁面專屬樣式 */
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
<!-- 頁面內容 -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// MesApi 和 Toast 已可用
|
||||
async function loadData() {
|
||||
const data = await MesApi.get('/api/wip/summary');
|
||||
// ...
|
||||
}
|
||||
|
||||
loadData();
|
||||
</script>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
### Toast CSS 樣式規格
|
||||
|
||||
內嵌在 `_base.html` 的 Toast 樣式:
|
||||
|
||||
```css
|
||||
/* 容器 */
|
||||
.mes-toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 單個 Toast */
|
||||
.mes-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-size: 14px;
|
||||
pointer-events: auto;
|
||||
animation: mes-toast-enter 0.3s ease-out;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
/* 類型顏色 */
|
||||
.mes-toast-info { border-left: 4px solid #3b82f6; }
|
||||
.mes-toast-success { border-left: 4px solid #22c55e; }
|
||||
.mes-toast-warning { border-left: 4px solid #f59e0b; }
|
||||
.mes-toast-error { border-left: 4px solid #ef4444; }
|
||||
.mes-toast-loading { border-left: 4px solid #6b7280; }
|
||||
|
||||
/* Icon 顏色 */
|
||||
.mes-toast-info .mes-toast-icon { color: #3b82f6; }
|
||||
.mes-toast-success .mes-toast-icon { color: #22c55e; }
|
||||
.mes-toast-warning .mes-toast-icon { color: #f59e0b; }
|
||||
.mes-toast-error .mes-toast-icon { color: #ef4444; }
|
||||
.mes-toast-loading .mes-toast-icon { color: #6b7280; }
|
||||
|
||||
/* Loading 旋轉動畫 */
|
||||
.mes-toast-loading .mes-toast-icon {
|
||||
animation: mes-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 按鈕 */
|
||||
.mes-toast-close, .mes-toast-retry {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mes-toast-close:hover { background: rgba(0,0,0,0.1); }
|
||||
.mes-toast-retry {
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mes-toast-retry:hover { background: rgba(59,130,246,0.1); }
|
||||
|
||||
/* 動畫 */
|
||||
@keyframes mes-toast-enter {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.mes-toast-exit {
|
||||
animation: mes-toast-exit 0.3s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes mes-toast-exit {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes mes-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
### 需遷移的頁面
|
||||
|
||||
以下頁面需改為繼承 `_base.html`:
|
||||
|
||||
| 頁面 | 檔案 | 備註 |
|
||||
|------|------|------|
|
||||
| Portal | `portal.html` | 首頁,可能不需要 API |
|
||||
| WIP Detail | `wip_detail.html` | 需移除內嵌 fetchWithTimeout |
|
||||
| WIP Overview | `wip_overview.html` | 需移除內嵌 fetchWithTimeout |
|
||||
| Tables | `index.html` | 需加入 MesApi 使用 |
|
||||
| Resource | `resource_status.html` | 需加入 MesApi 使用 |
|
||||
| Excel Query | `excel_query.html` | 需加入 MesApi 使用 |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `_base.html` 包含所有必要的 block 定義
|
||||
- [ ] Toast 容器 `#mes-toast-container` 存在於 body 內
|
||||
- [ ] `toast.js` 和 `mes-api.js` 正確載入
|
||||
- [ ] Toast CSS 樣式正確內嵌
|
||||
- [ ] 子頁面可透過 `{% extends "_base.html" %}` 繼承
|
||||
- [ ] 子頁面可使用 `MesApi` 和 `Toast` 全域物件
|
||||
- [ ] 所有 6 個現有頁面成功遷移
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `toast.js` (toast-notification capability)
|
||||
- `mes-api.js` (mes-api-client capability)
|
||||
|
||||
## File Location
|
||||
|
||||
`src/mes_dashboard/templates/_base.html`
|
||||
82
openspec/specs/mes-api-client/spec.md
Normal file
82
openspec/specs/mes-api-client/spec.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# mes-api-client
|
||||
|
||||
統一的前端 API Client 模組,提供 timeout、retry、cancellation 功能。
|
||||
|
||||
## Requirements
|
||||
|
||||
### 核心功能
|
||||
|
||||
1. **統一請求入口**
|
||||
- 提供 `MesApi.get(url, options)` 用於 GET 請求
|
||||
- 提供 `MesApi.post(url, data, options)` 用於 POST 請求
|
||||
- 所有頁面必須透過此模組發送 API 請求
|
||||
|
||||
2. **Timeout 處理**
|
||||
- 預設 timeout: 30 秒
|
||||
- 可透過 `options.timeout` 覆蓋
|
||||
- Timeout 時自動觸發重試機制
|
||||
|
||||
3. **Exponential Backoff Retry**
|
||||
- 預設重試 3 次
|
||||
- 重試間隔: 1s → 2s → 4s (exponential backoff)
|
||||
- 可透過 `options.retries` 覆蓋重試次數
|
||||
- 重試時顯示 Toast 通知
|
||||
|
||||
4. **請求取消 (Cancellation)**
|
||||
- 支援傳入 `options.signal` (AbortController.signal)
|
||||
- 取消的請求不觸發重試,不顯示錯誤 Toast
|
||||
- Console 記錄 `⊘ Aborted`
|
||||
|
||||
5. **Request ID 追蹤**
|
||||
- 每個請求自動生成唯一 ID (如 `req_1a2b3c`)
|
||||
- Console log 包含 request ID,便於追蹤
|
||||
|
||||
### 錯誤處理
|
||||
|
||||
| 錯誤類型 | 重試 | Toast | 說明 |
|
||||
|----------|------|-------|------|
|
||||
| Timeout | ✓ | 顯示重試狀態 | 網路慢或 server 忙 |
|
||||
| Network Error | ✓ | 顯示重試狀態 | fetch 失敗 |
|
||||
| 5xx Server Error | ✓ | 顯示重試狀態 | Server 暫時錯誤 |
|
||||
| 4xx Client Error | ✗ | 顯示錯誤 | 參數錯誤 |
|
||||
| Aborted | ✗ | 無 | 使用者/程式取消 |
|
||||
|
||||
### Options 參數
|
||||
|
||||
```javascript
|
||||
{
|
||||
params: Object, // URL query parameters
|
||||
timeout: Number, // 覆蓋預設 timeout (ms)
|
||||
retries: Number, // 覆蓋預設重試次數
|
||||
signal: AbortSignal, // 用於取消請求
|
||||
silent: Boolean, // true = 不顯示 Toast
|
||||
}
|
||||
```
|
||||
|
||||
### Console Logging 格式
|
||||
|
||||
```
|
||||
[MesApi] req_xxx GET /api/path
|
||||
[MesApi] req_xxx ✓ 200 (234ms)
|
||||
[MesApi] req_xxx ✗ Retry 1/3 in 1000ms
|
||||
[MesApi] req_xxx ⊘ Aborted
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `MesApi.get()` 可正常發送 GET 請求並返回 JSON
|
||||
- [ ] `MesApi.post()` 可正常發送 POST 請求並返回 JSON
|
||||
- [ ] Timeout 超過時自動重試,顯示 "正在重試 (N/3)..." Toast
|
||||
- [ ] 重試 3 次後失敗,顯示錯誤 Toast 附帶重試按鈕
|
||||
- [ ] 傳入 signal 並 abort 時,請求被取消,無錯誤 Toast
|
||||
- [ ] 4xx 錯誤不重試,直接顯示錯誤
|
||||
- [ ] 所有請求在 console 有 log,包含 request ID
|
||||
- [ ] `options.silent = true` 時不顯示任何 Toast
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `toast.js` (toast-notification capability)
|
||||
|
||||
## File Location
|
||||
|
||||
`src/mes_dashboard/static/js/mes-api.js`
|
||||
105
openspec/specs/toast-notification/spec.md
Normal file
105
openspec/specs/toast-notification/spec.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# toast-notification
|
||||
|
||||
前端通知系統,顯示 info/success/warning/error/loading 狀態訊息。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Toast 類型
|
||||
|
||||
| Type | 顏色 | Icon | 自動消失 | 用途 |
|
||||
|------|------|------|----------|------|
|
||||
| info | 藍色 (#3b82f6) | ℹ | 3 秒 | 一般資訊 |
|
||||
| success | 綠色 (#22c55e) | ✓ | 2 秒 | 操作成功 |
|
||||
| warning | 橙色 (#f59e0b) | ⚠ | 5 秒 | 警告訊息 |
|
||||
| error | 紅色 (#ef4444) | ✗ | 不自動消失 | 錯誤訊息 |
|
||||
| loading | 灰色 (#6b7280) | ⟳ (動畫) | 不自動消失 | 載入中 |
|
||||
|
||||
### API 設計
|
||||
|
||||
```javascript
|
||||
// 基本使用
|
||||
Toast.info('訊息內容');
|
||||
Toast.success('操作成功');
|
||||
Toast.warning('請注意');
|
||||
Toast.error('發生錯誤');
|
||||
|
||||
// Error 附帶重試按鈕
|
||||
Toast.error('連線失敗', { retry: () => loadData() });
|
||||
|
||||
// Loading 狀態
|
||||
const id = Toast.loading('載入中...');
|
||||
|
||||
// 更新 Toast
|
||||
Toast.update(id, { type: 'success', message: '完成!' });
|
||||
|
||||
// 手動關閉
|
||||
Toast.dismiss(id);
|
||||
```
|
||||
|
||||
### 顯示位置與行為
|
||||
|
||||
1. **位置**: 畫面右上角,距離頂部 20px,距離右側 20px
|
||||
2. **堆疊**: 多個 Toast 垂直堆疊,最新的在最上方
|
||||
3. **最大數量**: 同時最多顯示 5 個,超過時移除最舊的
|
||||
4. **動畫**:
|
||||
- 進入: 從右側滑入 (0.3s ease-out)
|
||||
- 離開: 向右滑出並淡出 (0.3s ease-in)
|
||||
|
||||
### UI 元素
|
||||
|
||||
每個 Toast 包含:
|
||||
1. **Icon**: 對應類型的圖示
|
||||
2. **Message**: 訊息文字
|
||||
3. **Close Button**: 右側 × 按鈕,點擊關閉
|
||||
4. **Retry Button** (可選): 僅 error 類型,點擊觸發 retry callback
|
||||
|
||||
### CSS Class 命名
|
||||
|
||||
使用 `mes-` 前綴避免衝突:
|
||||
- `.mes-toast-container` - 容器
|
||||
- `.mes-toast` - 單個 Toast
|
||||
- `.mes-toast-info` / `.mes-toast-success` / ... - 類型樣式
|
||||
- `.mes-toast-icon` - 圖示
|
||||
- `.mes-toast-message` - 訊息
|
||||
- `.mes-toast-close` - 關閉按鈕
|
||||
- `.mes-toast-retry` - 重試按鈕
|
||||
- `.mes-toast-exit` - 離開動畫
|
||||
|
||||
### Loading Icon 動畫
|
||||
|
||||
```css
|
||||
.mes-toast-loading .mes-toast-icon {
|
||||
animation: mes-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes mes-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `Toast.info()` 顯示藍色 Toast,3 秒後自動消失
|
||||
- [ ] `Toast.success()` 顯示綠色 Toast,2 秒後自動消失
|
||||
- [ ] `Toast.warning()` 顯示橙色 Toast,5 秒後自動消失
|
||||
- [ ] `Toast.error()` 顯示紅色 Toast,不自動消失
|
||||
- [ ] `Toast.error(msg, { retry: fn })` 顯示重試按鈕,點擊觸發 fn
|
||||
- [ ] `Toast.loading()` 顯示帶旋轉動畫的 Toast
|
||||
- [ ] `Toast.update()` 可更新現有 Toast 的類型和訊息
|
||||
- [ ] `Toast.dismiss()` 可手動關閉 Toast
|
||||
- [ ] 點擊 × 按鈕可關閉 Toast
|
||||
- [ ] Toast 有進入/離開動畫
|
||||
- [ ] 同時最多顯示 5 個 Toast
|
||||
|
||||
## Dependencies
|
||||
|
||||
無(獨立模組)
|
||||
|
||||
## File Location
|
||||
|
||||
`src/mes_dashboard/static/js/toast.js`
|
||||
|
||||
## 樣式內嵌於 `_base.html`
|
||||
|
||||
Toast 的 CSS 樣式將內嵌在 `_base.html` 的 `<style>` 區塊中,確保所有頁面都有樣式定義。
|
||||
@@ -30,6 +30,13 @@ dependencies = [
|
||||
"waitress>=2.1.2; platform_system == 'Windows'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-playwright>=0.4.0",
|
||||
"playwright>=1.40.0",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
include-package-data = true
|
||||
|
||||
10
pytest.ini
Normal file
10
pytest.ini
Normal file
@@ -0,0 +1,10 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts = -v --tb=short
|
||||
|
||||
markers =
|
||||
integration: mark test as integration test (requires database)
|
||||
e2e: mark test as end-to-end test (requires running server and playwright)
|
||||
276
src/mes_dashboard/static/js/mes-api.js
Normal file
276
src/mes_dashboard/static/js/mes-api.js
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* MES API Client
|
||||
*
|
||||
* Unified API client with timeout, retry, and cancellation support.
|
||||
*
|
||||
* Usage:
|
||||
* const data = await MesApi.get('/api/wip/summary');
|
||||
* const data = await MesApi.post('/api/query_table', { table_name: 'xxx' });
|
||||
*
|
||||
* // With options
|
||||
* const data = await MesApi.get('/api/xxx', {
|
||||
* params: { page: 1 },
|
||||
* timeout: 60000,
|
||||
* retries: 5,
|
||||
* signal: abortController.signal,
|
||||
* silent: true
|
||||
* });
|
||||
*/
|
||||
const MesApi = (function() {
|
||||
'use strict';
|
||||
|
||||
const DEFAULT_TIMEOUT = 30000; // 30 seconds
|
||||
const DEFAULT_RETRIES = 3;
|
||||
const RETRY_DELAYS = [1000, 2000, 4000]; // exponential backoff
|
||||
|
||||
let requestCounter = 0;
|
||||
|
||||
/**
|
||||
* Generate a unique request ID
|
||||
*/
|
||||
function generateRequestId() {
|
||||
const id = (++requestCounter).toString(36);
|
||||
return `req_${id.padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL with query parameters
|
||||
*/
|
||||
function buildUrl(url, params) {
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return url;
|
||||
}
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, value);
|
||||
}
|
||||
}
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is retryable
|
||||
*/
|
||||
function isRetryable(error, response) {
|
||||
// Network errors are retryable
|
||||
if (error && error.name === 'TypeError') {
|
||||
return true;
|
||||
}
|
||||
// Timeout is retryable
|
||||
if (error && error.name === 'TimeoutError') {
|
||||
return true;
|
||||
}
|
||||
// 5xx errors are retryable
|
||||
if (response && response.status >= 500) {
|
||||
return true;
|
||||
}
|
||||
// 4xx errors are NOT retryable
|
||||
if (response && response.status >= 400 && response.status < 500) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for a given duration
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute fetch with timeout
|
||||
*/
|
||||
async function fetchWithTimeout(url, fetchOptions, timeout, externalSignal) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
// Link external signal if provided
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
controller.abort();
|
||||
} else {
|
||||
externalSignal.addEventListener('abort', () => controller.abort());
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
// Distinguish between timeout and user abort
|
||||
if (error.name === 'AbortError') {
|
||||
if (externalSignal && externalSignal.aborted) {
|
||||
error.isUserAbort = true;
|
||||
} else {
|
||||
// Timeout
|
||||
const timeoutError = new Error('Request timeout');
|
||||
timeoutError.name = 'TimeoutError';
|
||||
throw timeoutError;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core request function with retry logic
|
||||
*/
|
||||
async function request(method, url, options = {}) {
|
||||
const reqId = generateRequestId();
|
||||
const timeout = options.timeout || DEFAULT_TIMEOUT;
|
||||
const maxRetries = options.retries !== undefined ? options.retries : DEFAULT_RETRIES;
|
||||
const silent = options.silent || false;
|
||||
const signal = options.signal;
|
||||
|
||||
const fullUrl = buildUrl(url, options.params);
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log(`[MesApi] ${reqId} ${method} ${fullUrl}`);
|
||||
|
||||
const fetchOptions = {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
if (options.body) {
|
||||
fetchOptions.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
let lastError = null;
|
||||
let lastResponse = null;
|
||||
let loadingToastId = null;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
// Check if already aborted
|
||||
if (signal && signal.aborted) {
|
||||
console.log(`[MesApi] ${reqId} ⊘ Aborted`);
|
||||
const abortError = new Error('Request aborted');
|
||||
abortError.name = 'AbortError';
|
||||
abortError.isUserAbort = true;
|
||||
throw abortError;
|
||||
}
|
||||
|
||||
const response = await fetchWithTimeout(fullUrl, fetchOptions, timeout, signal);
|
||||
lastResponse = response;
|
||||
|
||||
if (response.ok) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.log(`[MesApi] ${reqId} ✓ ${response.status} (${elapsed}ms)`);
|
||||
|
||||
// Dismiss loading toast if showing retry status
|
||||
if (loadingToastId) {
|
||||
Toast.dismiss(loadingToastId);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
// Non-OK response
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const error = new Error(errorData.error || `HTTP ${response.status}`);
|
||||
error.status = response.status;
|
||||
error.data = errorData;
|
||||
|
||||
// 4xx errors - don't retry
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
console.log(`[MesApi] ${reqId} ✗ ${response.status} (no retry)`);
|
||||
if (!silent) {
|
||||
Toast.error(`請求錯誤: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 5xx errors - will retry
|
||||
lastError = error;
|
||||
|
||||
} catch (error) {
|
||||
// User abort - don't retry, no toast
|
||||
if (error.isUserAbort) {
|
||||
console.log(`[MesApi] ${reqId} ⊘ Aborted`);
|
||||
if (loadingToastId) {
|
||||
Toast.dismiss(loadingToastId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
// Check if we should retry
|
||||
if (attempt < maxRetries && isRetryable(lastError, lastResponse)) {
|
||||
const delay = RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1];
|
||||
console.log(`[MesApi] ${reqId} ✗ Retry ${attempt + 1}/${maxRetries} in ${delay}ms`);
|
||||
|
||||
if (!silent) {
|
||||
const retryMsg = `正在重試 (${attempt + 1}/${maxRetries})...`;
|
||||
if (loadingToastId) {
|
||||
Toast.update(loadingToastId, { message: retryMsg });
|
||||
} else {
|
||||
loadingToastId = Toast.loading(retryMsg);
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.log(`[MesApi] ${reqId} ✗ Failed after ${maxRetries} retries (${elapsed}ms)`);
|
||||
|
||||
// Update or dismiss loading toast, show error with retry button
|
||||
if (loadingToastId) {
|
||||
Toast.dismiss(loadingToastId);
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
const errorMsg = lastError.message || '請求失敗';
|
||||
Toast.error(`${errorMsg}`, {
|
||||
retry: () => request(method, url, options)
|
||||
});
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
/**
|
||||
* Send a GET request
|
||||
* @param {string} url - The URL to request
|
||||
* @param {Object} options - Request options
|
||||
* @param {Object} options.params - URL query parameters
|
||||
* @param {number} options.timeout - Timeout in ms (default: 30000)
|
||||
* @param {number} options.retries - Max retries (default: 3)
|
||||
* @param {AbortSignal} options.signal - AbortController signal
|
||||
* @param {boolean} options.silent - Suppress toast notifications
|
||||
* @returns {Promise<any>} Response data
|
||||
*/
|
||||
get: function(url, options = {}) {
|
||||
return request('GET', url, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a POST request
|
||||
* @param {string} url - The URL to request
|
||||
* @param {Object} data - Request body data
|
||||
* @param {Object} options - Request options (same as get)
|
||||
* @returns {Promise<any>} Response data
|
||||
*/
|
||||
post: function(url, data, options = {}) {
|
||||
return request('POST', url, { ...options, body: data });
|
||||
}
|
||||
};
|
||||
})();
|
||||
240
src/mes_dashboard/static/js/toast.js
Normal file
240
src/mes_dashboard/static/js/toast.js
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Toast Notification System
|
||||
*
|
||||
* Usage:
|
||||
* Toast.info('訊息內容');
|
||||
* Toast.success('操作成功');
|
||||
* Toast.warning('請注意');
|
||||
* Toast.error('發生錯誤');
|
||||
* Toast.error('連線失敗', { retry: () => loadData() });
|
||||
*
|
||||
* const id = Toast.loading('載入中...');
|
||||
* Toast.update(id, { type: 'success', message: '完成!' });
|
||||
* Toast.dismiss(id);
|
||||
*/
|
||||
const Toast = (function() {
|
||||
'use strict';
|
||||
|
||||
const MAX_TOASTS = 5;
|
||||
const AUTO_DISMISS = {
|
||||
info: 3000,
|
||||
success: 2000,
|
||||
warning: 5000,
|
||||
error: null, // no auto dismiss
|
||||
loading: null // no auto dismiss
|
||||
};
|
||||
|
||||
const ICONS = {
|
||||
info: 'ℹ',
|
||||
success: '✓',
|
||||
warning: '⚠',
|
||||
error: '✗',
|
||||
loading: '⟳'
|
||||
};
|
||||
|
||||
let toastId = 0;
|
||||
const activeToasts = new Map();
|
||||
|
||||
/**
|
||||
* Get or create the toast container
|
||||
*/
|
||||
function getContainer() {
|
||||
let container = document.getElementById('mes-toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'mes-toast-container';
|
||||
container.className = 'mes-toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a toast element
|
||||
*/
|
||||
function createToastElement(id, type, message, options) {
|
||||
const toast = document.createElement('div');
|
||||
toast.id = `mes-toast-${id}`;
|
||||
toast.className = `mes-toast mes-toast-${type}`;
|
||||
toast.setAttribute('role', 'alert');
|
||||
|
||||
// Icon
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'mes-toast-icon';
|
||||
icon.textContent = ICONS[type];
|
||||
toast.appendChild(icon);
|
||||
|
||||
// Message
|
||||
const msg = document.createElement('span');
|
||||
msg.className = 'mes-toast-message';
|
||||
msg.textContent = message;
|
||||
toast.appendChild(msg);
|
||||
|
||||
// Retry button (for error type with retry callback)
|
||||
if (type === 'error' && options && typeof options.retry === 'function') {
|
||||
const retryBtn = document.createElement('button');
|
||||
retryBtn.className = 'mes-toast-retry';
|
||||
retryBtn.textContent = '重試';
|
||||
retryBtn.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
dismiss(id);
|
||||
options.retry();
|
||||
};
|
||||
toast.appendChild(retryBtn);
|
||||
}
|
||||
|
||||
// Close button
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'mes-toast-close';
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
dismiss(id);
|
||||
};
|
||||
toast.appendChild(closeBtn);
|
||||
|
||||
return toast;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce max toasts limit - remove oldest if exceeded
|
||||
*/
|
||||
function enforceMaxToasts() {
|
||||
while (activeToasts.size >= MAX_TOASTS) {
|
||||
const oldestId = activeToasts.keys().next().value;
|
||||
dismiss(oldestId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification
|
||||
*/
|
||||
function show(type, message, options) {
|
||||
enforceMaxToasts();
|
||||
|
||||
const id = ++toastId;
|
||||
const container = getContainer();
|
||||
const toast = createToastElement(id, type, message, options);
|
||||
|
||||
// Insert at the top (newest first)
|
||||
container.insertBefore(toast, container.firstChild);
|
||||
|
||||
// Track active toast
|
||||
const toastData = { element: toast, type, message, options, timerId: null };
|
||||
activeToasts.set(id, toastData);
|
||||
|
||||
// Auto dismiss if applicable
|
||||
const dismissTime = AUTO_DISMISS[type];
|
||||
if (dismissTime) {
|
||||
toastData.timerId = setTimeout(() => dismiss(id), dismissTime);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing toast
|
||||
*/
|
||||
function update(id, updates) {
|
||||
const toastData = activeToasts.get(id);
|
||||
if (!toastData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { element, timerId } = toastData;
|
||||
|
||||
// Clear existing auto-dismiss timer
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
toastData.timerId = null;
|
||||
}
|
||||
|
||||
// Update type if provided
|
||||
if (updates.type && updates.type !== toastData.type) {
|
||||
element.className = `mes-toast mes-toast-${updates.type}`;
|
||||
const icon = element.querySelector('.mes-toast-icon');
|
||||
if (icon) {
|
||||
icon.textContent = ICONS[updates.type];
|
||||
}
|
||||
toastData.type = updates.type;
|
||||
|
||||
// Set auto-dismiss for new type
|
||||
const dismissTime = AUTO_DISMISS[updates.type];
|
||||
if (dismissTime) {
|
||||
toastData.timerId = setTimeout(() => dismiss(id), dismissTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Update message if provided
|
||||
if (updates.message !== undefined) {
|
||||
const msg = element.querySelector('.mes-toast-message');
|
||||
if (msg) {
|
||||
msg.textContent = updates.message;
|
||||
}
|
||||
toastData.message = updates.message;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a toast
|
||||
*/
|
||||
function dismiss(id) {
|
||||
const toastData = activeToasts.get(id);
|
||||
if (!toastData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { element, timerId } = toastData;
|
||||
|
||||
// Clear timer
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
|
||||
// Add exit animation
|
||||
element.classList.add('mes-toast-exit');
|
||||
|
||||
// Remove after animation
|
||||
setTimeout(() => {
|
||||
if (element.parentNode) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
activeToasts.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss all toasts
|
||||
*/
|
||||
function dismissAll() {
|
||||
for (const id of activeToasts.keys()) {
|
||||
dismiss(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
info: function(message, options) {
|
||||
return show('info', message, options);
|
||||
},
|
||||
success: function(message, options) {
|
||||
return show('success', message, options);
|
||||
},
|
||||
warning: function(message, options) {
|
||||
return show('warning', message, options);
|
||||
},
|
||||
error: function(message, options) {
|
||||
return show('error', message, options);
|
||||
},
|
||||
loading: function(message, options) {
|
||||
return show('loading', message, options);
|
||||
},
|
||||
update: update,
|
||||
dismiss: dismiss,
|
||||
dismissAll: dismissAll
|
||||
};
|
||||
})();
|
||||
122
src/mes_dashboard/templates/_base.html
Normal file
122
src/mes_dashboard/templates/_base.html
Normal file
@@ -0,0 +1,122 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}MES Dashboard{% endblock %}</title>
|
||||
|
||||
<!-- Toast 樣式 -->
|
||||
<style id="mes-core-styles">
|
||||
/* Toast 容器 */
|
||||
.mes-toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 單個 Toast */
|
||||
.mes-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-size: 14px;
|
||||
pointer-events: auto;
|
||||
animation: mes-toast-enter 0.3s ease-out;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
/* 類型顏色 */
|
||||
.mes-toast-info { border-left: 4px solid #3b82f6; }
|
||||
.mes-toast-success { border-left: 4px solid #22c55e; }
|
||||
.mes-toast-warning { border-left: 4px solid #f59e0b; }
|
||||
.mes-toast-error { border-left: 4px solid #ef4444; }
|
||||
.mes-toast-loading { border-left: 4px solid #6b7280; }
|
||||
|
||||
/* Icon 顏色 */
|
||||
.mes-toast-info .mes-toast-icon { color: #3b82f6; }
|
||||
.mes-toast-success .mes-toast-icon { color: #22c55e; }
|
||||
.mes-toast-warning .mes-toast-icon { color: #f59e0b; }
|
||||
.mes-toast-error .mes-toast-icon { color: #ef4444; }
|
||||
.mes-toast-loading .mes-toast-icon { color: #6b7280; }
|
||||
|
||||
/* Loading 旋轉動畫 */
|
||||
.mes-toast-loading .mes-toast-icon {
|
||||
animation: mes-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Toast 訊息 */
|
||||
.mes-toast-message {
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 按鈕 */
|
||||
.mes-toast-close, .mes-toast-retry {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mes-toast-close {
|
||||
color: #6b7280;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
.mes-toast-close:hover { background: rgba(0,0,0,0.1); }
|
||||
|
||||
.mes-toast-retry {
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mes-toast-retry:hover { background: rgba(59,130,246,0.1); }
|
||||
|
||||
/* 進入動畫 */
|
||||
@keyframes mes-toast-enter {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 離開動畫 */
|
||||
.mes-toast-exit {
|
||||
animation: mes-toast-exit 0.3s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes mes-toast-exit {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
|
||||
/* 旋轉動畫 */
|
||||
@keyframes mes-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Toast 容器 -->
|
||||
<div id="mes-toast-container" class="mes-toast-container"></div>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<!-- 核心 JS -->
|
||||
<script src="{{ url_for('static', filename='js/toast.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/mes-api.js') }}"></script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Excel 批次查詢工具</title>
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% block title %}Excel 批次查詢工具{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
@@ -341,8 +340,9 @@
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Excel 批次查詢工具</h1>
|
||||
@@ -447,7 +447,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// State
|
||||
let excelColumns = [];
|
||||
@@ -471,6 +473,7 @@
|
||||
document.getElementById('uploadInfo').innerHTML = '<div class="loading"><div class="loading-spinner"></div><br>上傳中...</div>';
|
||||
|
||||
try {
|
||||
// Note: File upload uses native fetch since MesApi doesn't support FormData
|
||||
const response = await fetch('/api/excel-query/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
@@ -489,20 +492,15 @@
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show preview table
|
||||
renderPreviewTable(data.columns, data.preview);
|
||||
|
||||
// Populate Excel column dropdown
|
||||
const select = document.getElementById('excelColumn');
|
||||
select.innerHTML = '<option value="">-- 請選擇 --</option>';
|
||||
excelColumns.forEach(col => {
|
||||
select.innerHTML += `<option value="${col}">${col}</option>`;
|
||||
});
|
||||
|
||||
// Enable step 2
|
||||
document.getElementById('step2').classList.remove('disabled');
|
||||
|
||||
// Load available tables
|
||||
loadTables();
|
||||
|
||||
} catch (error) {
|
||||
@@ -544,12 +542,7 @@
|
||||
document.getElementById('columnInfo').innerHTML = '<div class="loading"><div class="loading-spinner"></div><br>讀取中...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/excel-query/column-values', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ column_name: column })
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await MesApi.post('/api/excel-query/column-values', { column_name: column });
|
||||
|
||||
if (data.error) {
|
||||
document.getElementById('columnInfo').innerHTML = `<div class="error">${data.error}</div>`;
|
||||
@@ -565,7 +558,6 @@
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Enable step 3
|
||||
document.getElementById('step3').classList.remove('disabled');
|
||||
|
||||
} catch (error) {
|
||||
@@ -576,8 +568,7 @@
|
||||
// Load available tables
|
||||
async function loadTables() {
|
||||
try {
|
||||
const response = await fetch('/api/excel-query/tables');
|
||||
const data = await response.json();
|
||||
const data = await MesApi.get('/api/excel-query/tables', { silent: true });
|
||||
|
||||
const select = document.getElementById('targetTable');
|
||||
select.innerHTML = '<option value="">-- 請選擇 --</option>';
|
||||
@@ -602,12 +593,7 @@
|
||||
document.getElementById('tableInfo').innerHTML = '<div class="loading"><div class="loading-spinner"></div><br>讀取欄位...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/excel-query/table-columns', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ table_name: tableName })
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await MesApi.post('/api/excel-query/table-columns', { table_name: tableName });
|
||||
|
||||
if (data.error) {
|
||||
document.getElementById('tableInfo').innerHTML = `<div class="error">${data.error}</div>`;
|
||||
@@ -619,14 +605,12 @@
|
||||
<div class="info-box">共 ${data.columns.length} 個欄位</div>
|
||||
`;
|
||||
|
||||
// Populate search column dropdown
|
||||
const searchSelect = document.getElementById('searchColumn');
|
||||
searchSelect.innerHTML = '<option value="">-- 請選擇 --</option>';
|
||||
tableColumns.forEach(col => {
|
||||
searchSelect.innerHTML += `<option value="${col}">${col}</option>`;
|
||||
});
|
||||
|
||||
// Populate return columns checkboxes
|
||||
const container = document.getElementById('returnColumns');
|
||||
container.innerHTML = '';
|
||||
tableColumns.forEach(col => {
|
||||
@@ -638,7 +622,6 @@
|
||||
`;
|
||||
});
|
||||
|
||||
// Enable step 4 and 5
|
||||
document.getElementById('step4').classList.remove('disabled');
|
||||
document.getElementById('step5').classList.remove('disabled');
|
||||
|
||||
@@ -707,12 +690,7 @@
|
||||
document.getElementById('resultSection').classList.remove('active');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/excel-query/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params)
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await MesApi.post('/api/excel-query/execute', params);
|
||||
|
||||
if (data.error) {
|
||||
document.getElementById('executeInfo').innerHTML = `<div class="error">${data.error}</div>`;
|
||||
@@ -753,7 +731,6 @@
|
||||
});
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
// Show first 1000 rows in preview
|
||||
const previewData = data.data.slice(0, 1000);
|
||||
previewData.forEach(row => {
|
||||
html += '<tr>';
|
||||
@@ -793,6 +770,7 @@
|
||||
`;
|
||||
|
||||
try {
|
||||
// Note: CSV export uses native fetch for blob response
|
||||
const response = await fetch('/api/excel-query/export-csv', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -805,7 +783,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Download file
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -825,5 +802,4 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MES 數據表查詢工具</title>
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% block title %}MES 數據表查詢工具{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
@@ -333,8 +332,9 @@
|
||||
color: #dc3545;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>MES 數據表查詢工具</h1>
|
||||
@@ -354,9 +354,9 @@
|
||||
<span class="badge large">大表</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="table-info">📦 數據量: {{ "{:,}".format(table.row_count) }} 行</div>
|
||||
<div class="table-info">數據量: {{ "{:,}".format(table.row_count) }} 行</div>
|
||||
{% if table.time_field %}
|
||||
<div class="table-info">🕐 時間欄位: {{ table.time_field }}</div>
|
||||
<div class="table-info">時間欄位: {{ table.time_field }}</div>
|
||||
{% endif %}
|
||||
<div class="table-desc">{{ table.description }}</div>
|
||||
</div>
|
||||
@@ -377,7 +377,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let currentTable = null;
|
||||
let currentDisplayName = null;
|
||||
@@ -385,8 +387,8 @@
|
||||
let currentColumns = [];
|
||||
let currentFilters = {};
|
||||
|
||||
function loadTableData(tableName, displayName, timeField) {
|
||||
// 標記當前選中的表
|
||||
async function loadTableData(tableName, displayName, timeField) {
|
||||
// Mark current selected table
|
||||
document.querySelectorAll('.table-card').forEach(card => {
|
||||
card.classList.remove('active');
|
||||
});
|
||||
@@ -402,23 +404,16 @@
|
||||
const content = document.getElementById('tableContent');
|
||||
const statsContainer = document.getElementById('statsContainer');
|
||||
|
||||
// 顯示查看器
|
||||
viewer.classList.add('active');
|
||||
title.textContent = `正在載入: ${displayName}`;
|
||||
content.innerHTML = '<div class="loading">正在載入欄位資訊...</div>';
|
||||
statsContainer.innerHTML = '';
|
||||
|
||||
// 滾動到查看器
|
||||
viewer.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
// 先取得欄位資訊
|
||||
fetch('/api/get_table_columns', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ table_name: tableName })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
try {
|
||||
const data = await MesApi.post('/api/get_table_columns', { table_name: tableName });
|
||||
|
||||
if (data.error) {
|
||||
content.innerHTML = `<div class="error">${data.error}</div>`;
|
||||
return;
|
||||
@@ -427,19 +422,16 @@
|
||||
currentColumns = data.columns;
|
||||
title.textContent = `${displayName} (${currentColumns.length} 欄位)`;
|
||||
|
||||
// 顯示篩選控制區
|
||||
renderFilterControls();
|
||||
})
|
||||
.catch(error => {
|
||||
} catch (error) {
|
||||
content.innerHTML = `<div class="error">請求失敗: ${error.message}</div>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderFilterControls() {
|
||||
const statsContainer = document.getElementById('statsContainer');
|
||||
const content = document.getElementById('tableContent');
|
||||
|
||||
// 統計區 + 查詢按鈕
|
||||
statsContainer.innerHTML = `
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
@@ -457,17 +449,13 @@
|
||||
<div id="activeFilters" class="active-filters"></div>
|
||||
`;
|
||||
|
||||
// 生成表格結構 (含篩選輸入行)
|
||||
let html = '<table><thead>';
|
||||
|
||||
// 欄位名稱行
|
||||
html += '<tr>';
|
||||
currentColumns.forEach(col => {
|
||||
html += `<th>${col}</th>`;
|
||||
});
|
||||
html += '</tr>';
|
||||
|
||||
// 篩選輸入行
|
||||
html += '<tr class="filter-row">';
|
||||
currentColumns.forEach(col => {
|
||||
html += `<th><input type="text" id="filter_${col}" placeholder="篩選..." onkeypress="handleFilterKeypress(event)" onchange="updateFilter('${col}', this.value)"></th>`;
|
||||
@@ -529,11 +517,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function executeQuery() {
|
||||
async function executeQuery() {
|
||||
const title = document.getElementById('viewerTitle');
|
||||
const tbody = document.getElementById('dataBody');
|
||||
|
||||
// 收集所有篩選條件
|
||||
currentFilters = {};
|
||||
currentColumns.forEach(col => {
|
||||
const input = document.getElementById(`filter_${col}`);
|
||||
@@ -546,30 +533,23 @@
|
||||
title.textContent = `正在查詢: ${currentDisplayName}`;
|
||||
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" class="loading">正在查詢資料...</td></tr>`;
|
||||
|
||||
// 發送查詢請求
|
||||
fetch('/api/query_table', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
try {
|
||||
const data = await MesApi.post('/api/query_table', {
|
||||
table_name: currentTable,
|
||||
limit: 1000,
|
||||
time_field: currentTimeField,
|
||||
filters: Object.keys(currentFilters).length > 0 ? currentFilters : null
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
});
|
||||
|
||||
if (data.error) {
|
||||
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" class="error">${data.error}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新標題
|
||||
const filterCount = Object.keys(currentFilters).length;
|
||||
const filterText = filterCount > 0 ? ` [${filterCount} 個篩選]` : '';
|
||||
title.textContent = `${currentDisplayName} (${data.row_count} 筆)${filterText}`;
|
||||
|
||||
// 生成資料行
|
||||
if (data.data.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" style="text-align: center; padding: 40px; color: #999;">查無資料</td></tr>`;
|
||||
return;
|
||||
@@ -586,10 +566,9 @@
|
||||
html += '</tr>';
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
})
|
||||
.catch(error => {
|
||||
} catch (error) {
|
||||
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" class="error">請求失敗: ${error.message}</td></tr>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closeViewer() {
|
||||
@@ -602,5 +581,4 @@
|
||||
currentFilters = {};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MES 報表入口</title>
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% block title %}MES 報表入口{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
@@ -87,8 +86,9 @@
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="shell">
|
||||
<div class="header">
|
||||
<h1>MES 報表入口</h1>
|
||||
@@ -110,7 +110,9 @@
|
||||
<iframe id="excelQueryFrame" data-src="/excel-query" title="Excel 批次查詢"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
const frames = document.querySelectorAll('iframe');
|
||||
@@ -146,5 +148,4 @@
|
||||
window.addEventListener('resize', setFrameHeight);
|
||||
setFrameHeight();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<!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>
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% block title %}全廠機況 Dashboard{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script src="/static/js/echarts.min.js"></script>
|
||||
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f7fa;
|
||||
@@ -370,9 +369,9 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
@@ -384,8 +383,8 @@
|
||||
<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>
|
||||
@@ -456,24 +455,25 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let selectedWorkcenter = null;
|
||||
let selectedOriginalWcs = null; // 儲存合併群組的原始工站列表
|
||||
let workcenterData = {}; // 快取工站資料
|
||||
let selectedOriginalWcs = null;
|
||||
let workcenterData = {};
|
||||
let isLoading = false;
|
||||
let miniCharts = {}; // 快取 ECharts 實例
|
||||
let ouTrendChart = null; // OU趨勢圖實例
|
||||
let heatmapChart = null; // 熱力圖實例
|
||||
let miniCharts = {};
|
||||
let ouTrendChart = null;
|
||||
let heatmapChart = null;
|
||||
|
||||
// 狀態顏色定義 (統一)
|
||||
const STATUS_COLORS = {
|
||||
PRD: '#00ff88', // 綠色 - 生產中
|
||||
SBY: '#17a2b8', // 青色 - 待機
|
||||
UDT: '#ff4757', // 紅色 - 非計畫停機
|
||||
SDT: '#ffc107', // 黃色 - 計畫停機
|
||||
EGT: '#6c757d', // 灰色 - 工程時間
|
||||
NST: '#9b59b6' // 紫色 - NST
|
||||
PRD: '#00ff88',
|
||||
SBY: '#17a2b8',
|
||||
UDT: '#ff4757',
|
||||
SDT: '#ffc107',
|
||||
EGT: '#6c757d',
|
||||
NST: '#9b59b6'
|
||||
};
|
||||
|
||||
function getFilters() {
|
||||
@@ -482,8 +482,6 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -500,35 +498,24 @@
|
||||
|
||||
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();
|
||||
const result = await MesApi.post('/api/dashboard/kpi', { filters: getFilters() });
|
||||
|
||||
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)}`;
|
||||
}
|
||||
@@ -542,15 +529,9 @@
|
||||
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();
|
||||
const result = await MesApi.post('/api/dashboard/workcenter_cards', { filters: getFilters() });
|
||||
|
||||
if (result.success && result.data.length > 0) {
|
||||
// 快取工站資料
|
||||
workcenterData = {};
|
||||
result.data.forEach(wc => {
|
||||
workcenterData[wc.workcenter] = wc;
|
||||
@@ -561,7 +542,6 @@
|
||||
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;
|
||||
@@ -584,7 +564,6 @@
|
||||
});
|
||||
grid.innerHTML = html;
|
||||
|
||||
// Render mini charts
|
||||
result.data.forEach(wc => {
|
||||
renderMiniChart(wc);
|
||||
});
|
||||
@@ -598,7 +577,6 @@
|
||||
}
|
||||
|
||||
function renderMiniChart(wc, retryCount = 0) {
|
||||
// 使用 encodeURIComponent 確保中文名稱生成唯一 ID
|
||||
const chartId = `miniChart_${encodeURIComponent(wc.workcenter)}`;
|
||||
const chartDom = document.getElementById(chartId);
|
||||
if (!chartDom) {
|
||||
@@ -606,12 +584,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// 銷毀舊的實例(如果存在)
|
||||
if (miniCharts[chartId]) {
|
||||
miniCharts[chartId].dispose();
|
||||
}
|
||||
|
||||
// 確保容器有尺寸,如果沒有則延遲重試
|
||||
if (chartDom.offsetWidth === 0 || chartDom.offsetHeight === 0) {
|
||||
if (retryCount < 3) {
|
||||
setTimeout(() => renderMiniChart(wc, retryCount + 1), 100);
|
||||
@@ -623,8 +599,6 @@
|
||||
miniCharts[chartId] = chart;
|
||||
|
||||
const total = wc.total || 1;
|
||||
|
||||
// 計算各狀態百分比
|
||||
const calcPct = (val) => ((val / total) * 100).toFixed(1);
|
||||
|
||||
const option = {
|
||||
@@ -656,66 +630,17 @@
|
||||
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 } }
|
||||
}
|
||||
{ name: 'PRD', type: 'bar', stack: 'total', data: [wc.prd], itemStyle: { color: STATUS_COLORS.PRD }, barWidth: '100%' },
|
||||
{ name: 'SBY', type: 'bar', stack: 'total', data: [wc.sby], itemStyle: { color: STATUS_COLORS.SBY }, barWidth: '100%' },
|
||||
{ name: 'UDT', type: 'bar', stack: 'total', data: [wc.udt], itemStyle: { color: STATUS_COLORS.UDT }, barWidth: '100%' },
|
||||
{ name: 'SDT', type: 'bar', stack: 'total', data: [wc.sdt], itemStyle: { color: STATUS_COLORS.SDT }, barWidth: '100%' },
|
||||
{ name: 'EGT', type: 'bar', stack: 'total', data: [wc.egt], itemStyle: { color: STATUS_COLORS.EGT }, barWidth: '100%' },
|
||||
{ name: 'NST', type: 'bar', stack: 'total', data: [wc.nst], itemStyle: { color: STATUS_COLORS.NST }, barWidth: '100%' }
|
||||
]
|
||||
};
|
||||
chart.setOption(option);
|
||||
}
|
||||
|
||||
// 全局 resize 監聽器 - 調整所有圖表大小
|
||||
window.addEventListener('resize', () => {
|
||||
Object.values(miniCharts).forEach(chart => {
|
||||
if (chart && !chart.isDisposed()) {
|
||||
@@ -730,7 +655,6 @@
|
||||
selectedOriginalWcs = null;
|
||||
} else {
|
||||
selectedWorkcenter = workcenter;
|
||||
// 取得合併群組的原始工站列表
|
||||
if (workcenterData[workcenter] && workcenterData[workcenter].original_wcs) {
|
||||
selectedOriginalWcs = workcenterData[workcenter].original_wcs;
|
||||
} else {
|
||||
@@ -738,7 +662,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Update card selection
|
||||
document.querySelectorAll('.wc-card').forEach(card => {
|
||||
card.classList.remove('selected');
|
||||
});
|
||||
@@ -750,21 +673,14 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ========== OU% 趨勢圖 ==========
|
||||
async function loadOuTrend() {
|
||||
const days = parseInt(document.getElementById('trendDays').value);
|
||||
const chartDom = document.getElementById('ouTrendChart');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/ou_trend', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filters: getFilters(), days: days })
|
||||
});
|
||||
const result = await response.json();
|
||||
const result = await MesApi.post('/api/dashboard/ou_trend', { filters: getFilters(), days: days });
|
||||
|
||||
if (result.success && result.data.length > 0) {
|
||||
renderOuTrendChart(result.data);
|
||||
@@ -784,7 +700,7 @@
|
||||
}
|
||||
ouTrendChart = echarts.init(chartDom);
|
||||
|
||||
const dates = data.map(d => d.date.substring(5)); // MM-DD
|
||||
const dates = data.map(d => d.date.substring(5));
|
||||
const ouValues = data.map(d => d.ou_pct);
|
||||
|
||||
const option = {
|
||||
@@ -802,12 +718,7 @@
|
||||
EGT: ${d.egt_hours}h`;
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: 50,
|
||||
right: 20,
|
||||
top: 30,
|
||||
bottom: 30
|
||||
},
|
||||
grid: { left: 50, right: 20, top: 30, bottom: 30 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
@@ -846,18 +757,12 @@
|
||||
ouTrendChart.setOption(option);
|
||||
}
|
||||
|
||||
// ========== 工站利用率熱力圖 ==========
|
||||
async function loadHeatmap() {
|
||||
const days = parseInt(document.getElementById('heatmapDays').value);
|
||||
const chartDom = document.getElementById('heatmapChart');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/utilization_heatmap', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filters: getFilters(), days: days })
|
||||
});
|
||||
const result = await response.json();
|
||||
const result = await MesApi.post('/api/dashboard/utilization_heatmap', { filters: getFilters(), days: days });
|
||||
|
||||
if (result.success && result.data.length > 0) {
|
||||
renderHeatmapChart(result.data, days);
|
||||
@@ -877,57 +782,36 @@
|
||||
}
|
||||
heatmapChart = echarts.init(chartDom);
|
||||
|
||||
// 工站排序順序定義 (與後端 WORKCENTER_GROUPS 對應)
|
||||
const GROUP_ORDER = {
|
||||
'切割': 0,
|
||||
'焊接_DB': 1,
|
||||
'焊接_WB': 2,
|
||||
'焊接_DW': 3,
|
||||
'成型': 4,
|
||||
'去膠': 5,
|
||||
'水吹砂': 6,
|
||||
'電鍍': 7,
|
||||
'移印': 8,
|
||||
'切彎腳': 9,
|
||||
'元件切割': 10,
|
||||
'測試': 11
|
||||
'切割': 0, '焊接_DB': 1, '焊接_WB': 2, '焊接_DW': 3, '成型': 4,
|
||||
'去膠': 5, '水吹砂': 6, '電鍍': 7, '移印': 8, '切彎腳': 9,
|
||||
'元件切割': 10, '測試': 11
|
||||
};
|
||||
|
||||
// 取得工站排序值
|
||||
function getGroupOrder(groupName) {
|
||||
if (GROUP_ORDER.hasOwnProperty(groupName)) {
|
||||
return GROUP_ORDER[groupName];
|
||||
}
|
||||
return 999; // 未定義的放最後
|
||||
return GROUP_ORDER.hasOwnProperty(groupName) ? GROUP_ORDER[groupName] : 999;
|
||||
}
|
||||
|
||||
// 整理資料:按群組聚合
|
||||
const groupedData = {};
|
||||
const allDates = new Set();
|
||||
|
||||
data.forEach(d => {
|
||||
const group = d.group || d.workcenter;
|
||||
if (!groupedData[group]) {
|
||||
groupedData[group] = {};
|
||||
}
|
||||
if (!groupedData[group][d.date]) {
|
||||
groupedData[group][d.date] = { prd: 0, avail: 0 };
|
||||
}
|
||||
if (!groupedData[group]) groupedData[group] = {};
|
||||
if (!groupedData[group][d.date]) groupedData[group][d.date] = { prd: 0, avail: 0 };
|
||||
groupedData[group][d.date].prd += d.prd_hours;
|
||||
groupedData[group][d.date].avail += d.avail_hours;
|
||||
allDates.add(d.date);
|
||||
});
|
||||
|
||||
// 排序:日期升序,工站按指定順序 (order 小的在上方,ECharts Y軸需反轉)
|
||||
const dates = Array.from(allDates).sort();
|
||||
const groups = Object.keys(groupedData).sort((a, b) => {
|
||||
const orderA = getGroupOrder(a);
|
||||
const orderB = getGroupOrder(b);
|
||||
if (orderA !== orderB) return orderB - orderA; // 反轉:讓 order 小的在上方
|
||||
return a.localeCompare(b); // 同 order 按字母排序
|
||||
if (orderA !== orderB) return orderB - orderA;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// 轉換為熱力圖資料格式 [x, y, value]
|
||||
const heatmapData = [];
|
||||
groups.forEach((group, yIdx) => {
|
||||
dates.forEach((date, xIdx) => {
|
||||
@@ -955,12 +839,7 @@
|
||||
return `<b>${group}</b><br/>${date}<br/>OU%: <b>${val}%</b><br/>PRD: ${cell?.prd?.toFixed(1) || 0}h`;
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: 100,
|
||||
right: 40,
|
||||
top: 10,
|
||||
bottom: 30
|
||||
},
|
||||
grid: { left: 100, right: 40, top: 10, bottom: 30 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates.map(d => d.substring(5)),
|
||||
@@ -974,16 +853,9 @@
|
||||
axisLabel: { fontSize: 10, color: '#666', width: 80, overflow: 'truncate' }
|
||||
},
|
||||
visualMap: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
calculable: true,
|
||||
orient: 'vertical',
|
||||
right: 0,
|
||||
top: 'center',
|
||||
itemHeight: 120,
|
||||
inRange: {
|
||||
color: ['#fee2e2', '#fef3c7', '#bbf7d0', '#22c55e']
|
||||
},
|
||||
min: 0, max: 100, calculable: true, orient: 'vertical',
|
||||
right: 0, top: 'center', itemHeight: 120,
|
||||
inRange: { color: ['#fee2e2', '#fef3c7', '#bbf7d0', '#22c55e'] },
|
||||
formatter: '{value}%'
|
||||
},
|
||||
series: [{
|
||||
@@ -992,13 +864,9 @@
|
||||
label: {
|
||||
show: days <= 7,
|
||||
fontSize: 9,
|
||||
formatter: function(params) {
|
||||
return params.data[2] === '-' ? '' : params.data[2];
|
||||
}
|
||||
formatter: params => params.data[2] === '-' ? '' : params.data[2]
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' }
|
||||
}
|
||||
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' } }
|
||||
}]
|
||||
};
|
||||
heatmapChart.setOption(option);
|
||||
@@ -1019,7 +887,6 @@
|
||||
loadOuTrend(),
|
||||
loadHeatmap()
|
||||
]);
|
||||
// 更新 Last Update
|
||||
document.getElementById('lastUpdate').textContent = `Last Update: ${new Date().toLocaleString('zh-TW')}`;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
@@ -1028,7 +895,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 視窗調整時重繪圖表
|
||||
window.addEventListener('resize', () => {
|
||||
if (ouTrendChart && !ouTrendChart.isDisposed()) ouTrendChart.resize();
|
||||
if (heatmapChart && !heatmapChart.isDisposed()) heatmapChart.resize();
|
||||
@@ -1036,8 +902,5 @@
|
||||
if (chart && !chart.isDisposed()) chart.resize();
|
||||
});
|
||||
});
|
||||
|
||||
// Page init - 使用標記確保只執行一次
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WIP Detail Dashboard</title>
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% block title %}WIP Detail Dashboard{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f7fa;
|
||||
@@ -615,8 +614,9 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
@@ -703,7 +703,9 @@
|
||||
<span class="loading-spinner"></span>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// ============================================================
|
||||
// State Management
|
||||
@@ -758,46 +760,12 @@
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Functions
|
||||
// API Functions (using MesApi)
|
||||
// ============================================================
|
||||
const API_TIMEOUT = 60000; // 60 seconds timeout
|
||||
|
||||
async function fetchWithTimeout(url, timeout = API_TIMEOUT, externalSignal = null) {
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeout);
|
||||
|
||||
// If external signal is provided, abort when it fires
|
||||
if (externalSignal) {
|
||||
externalSignal.addEventListener('abort', () => controller.abort());
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name === 'AbortError') {
|
||||
// Check if it was external cancellation or timeout
|
||||
if (externalSignal && externalSignal.aborted) {
|
||||
throw error; // Re-throw AbortError for external cancellation
|
||||
}
|
||||
if (timedOut) {
|
||||
throw new Error(`Request timeout after ${timeout/1000}s: ${url}`);
|
||||
}
|
||||
throw error; // Other abort (shouldn't happen, but be safe)
|
||||
}
|
||||
throw new Error(`Network error for ${url}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPackages() {
|
||||
const response = await fetchWithTimeout('/api/wip/meta/packages');
|
||||
const result = await response.json();
|
||||
const result = await MesApi.get('/api/wip/meta/packages', { silent: true, timeout: API_TIMEOUT });
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
@@ -805,27 +773,30 @@
|
||||
}
|
||||
|
||||
async function fetchDetail(signal = null) {
|
||||
const params = new URLSearchParams({
|
||||
const params = {
|
||||
page: state.page,
|
||||
page_size: state.pageSize
|
||||
});
|
||||
};
|
||||
|
||||
if (state.filters.package) {
|
||||
params.append('package', state.filters.package);
|
||||
params.package = state.filters.package;
|
||||
}
|
||||
if (activeStatusFilter) {
|
||||
// Convert to API status format (RUN/QUEUE/HOLD)
|
||||
params.append('status', activeStatusFilter.toUpperCase());
|
||||
params.status = activeStatusFilter.toUpperCase();
|
||||
}
|
||||
if (state.filters.workorder) {
|
||||
params.append('workorder', state.filters.workorder);
|
||||
params.workorder = state.filters.workorder;
|
||||
}
|
||||
if (state.filters.lotid) {
|
||||
params.append('lotid', state.filters.lotid);
|
||||
params.lotid = state.filters.lotid;
|
||||
}
|
||||
|
||||
const response = await fetchWithTimeout(`/api/wip/detail/${encodeURIComponent(state.workcenter)}?${params}`, API_TIMEOUT, signal);
|
||||
const result = await response.json();
|
||||
const result = await MesApi.get(`/api/wip/detail/${encodeURIComponent(state.workcenter)}`, {
|
||||
params,
|
||||
timeout: API_TIMEOUT,
|
||||
signal
|
||||
});
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
@@ -833,8 +804,7 @@
|
||||
}
|
||||
|
||||
async function fetchWorkcenters() {
|
||||
const response = await fetchWithTimeout('/api/wip/meta/workcenters');
|
||||
const result = await response.json();
|
||||
const result = await MesApi.get('/api/wip/meta/workcenters', { silent: true, timeout: API_TIMEOUT });
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
@@ -845,15 +815,17 @@
|
||||
if (!query || query.length < 2) {
|
||||
return [];
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
type: type,
|
||||
q: query,
|
||||
limit: 20
|
||||
});
|
||||
const response = await fetch(`/api/wip/meta/search?${params}`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
return result.data.items || [];
|
||||
try {
|
||||
const result = await MesApi.get('/api/wip/meta/search', {
|
||||
params: { type, q: query, limit: 20 },
|
||||
silent: true,
|
||||
retries: 0 // No retry for autocomplete
|
||||
});
|
||||
if (result.success) {
|
||||
return result.data.items || [];
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors for autocomplete
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -1317,5 +1289,4 @@
|
||||
|
||||
window.onload = init;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WIP Overview Dashboard</title>
|
||||
{% extends "_base.html" %}
|
||||
|
||||
{% block title %}WIP Overview Dashboard{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f7fa;
|
||||
@@ -631,8 +630,9 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
@@ -739,7 +739,9 @@
|
||||
<span class="loading-spinner"></span>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// ============================================================
|
||||
// State Management
|
||||
@@ -805,14 +807,62 @@
|
||||
}
|
||||
|
||||
function buildQueryParams() {
|
||||
const params = new URLSearchParams();
|
||||
const params = {};
|
||||
if (state.filters.workorder) {
|
||||
params.append('workorder', state.filters.workorder);
|
||||
params.workorder = state.filters.workorder;
|
||||
}
|
||||
if (state.filters.lotid) {
|
||||
params.append('lotid', state.filters.lotid);
|
||||
params.lotid = state.filters.lotid;
|
||||
}
|
||||
return params.toString();
|
||||
return params;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Functions (using MesApi)
|
||||
// ============================================================
|
||||
const API_TIMEOUT = 60000; // 60 seconds timeout
|
||||
|
||||
async function fetchSummary(signal = null) {
|
||||
const params = buildQueryParams();
|
||||
const result = await MesApi.get('/api/wip/overview/summary', {
|
||||
params,
|
||||
timeout: API_TIMEOUT,
|
||||
signal
|
||||
});
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Failed to fetch summary');
|
||||
}
|
||||
|
||||
async function fetchMatrix(signal = null) {
|
||||
const params = buildQueryParams();
|
||||
// Add status filter if active
|
||||
if (activeStatusFilter) {
|
||||
params.status = activeStatusFilter.toUpperCase();
|
||||
}
|
||||
const result = await MesApi.get('/api/wip/overview/matrix', {
|
||||
params,
|
||||
timeout: API_TIMEOUT,
|
||||
signal
|
||||
});
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Failed to fetch matrix');
|
||||
}
|
||||
|
||||
async function fetchHold(signal = null) {
|
||||
const params = buildQueryParams();
|
||||
const result = await MesApi.get('/api/wip/overview/hold', {
|
||||
params,
|
||||
timeout: API_TIMEOUT,
|
||||
signal
|
||||
});
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Failed to fetch hold');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -827,8 +877,11 @@
|
||||
loadingEl.classList.add('active');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/wip/meta/search?type=${type}&q=${encodeURIComponent(query)}&limit=20`);
|
||||
const result = await response.json();
|
||||
const result = await MesApi.get('/api/wip/meta/search', {
|
||||
params: { type, q: query, limit: 20 },
|
||||
silent: true,
|
||||
retries: 0 // No retry for autocomplete
|
||||
});
|
||||
if (result.success) {
|
||||
return result.data.items || [];
|
||||
}
|
||||
@@ -944,88 +997,6 @@
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Functions
|
||||
// ============================================================
|
||||
const API_TIMEOUT = 60000; // 60 seconds timeout
|
||||
|
||||
async function fetchWithTimeout(url, timeout = API_TIMEOUT, externalSignal = null) {
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeout);
|
||||
|
||||
// If external signal is provided, abort when it fires
|
||||
if (externalSignal) {
|
||||
externalSignal.addEventListener('abort', () => controller.abort());
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name === 'AbortError') {
|
||||
// Check if it was external cancellation or timeout
|
||||
if (externalSignal && externalSignal.aborted) {
|
||||
throw error; // Re-throw AbortError for external cancellation
|
||||
}
|
||||
if (timedOut) {
|
||||
throw new Error(`Request timeout after ${timeout/1000}s: ${url}`);
|
||||
}
|
||||
throw error; // Other abort (shouldn't happen, but be safe)
|
||||
}
|
||||
throw new Error(`Network error for ${url}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSummary(signal = null) {
|
||||
const queryParams = buildQueryParams();
|
||||
const url = `/api/wip/overview/summary${queryParams ? '?' + queryParams : ''}`;
|
||||
const response = await fetchWithTimeout(url, API_TIMEOUT, signal);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Failed to fetch summary');
|
||||
}
|
||||
|
||||
async function fetchMatrix(signal = null) {
|
||||
const params = new URLSearchParams();
|
||||
if (state.filters.workorder) {
|
||||
params.append('workorder', state.filters.workorder);
|
||||
}
|
||||
if (state.filters.lotid) {
|
||||
params.append('lotid', state.filters.lotid);
|
||||
}
|
||||
// Add status filter if active
|
||||
if (activeStatusFilter) {
|
||||
params.append('status', activeStatusFilter.toUpperCase());
|
||||
}
|
||||
const queryParams = params.toString();
|
||||
const url = `/api/wip/overview/matrix${queryParams ? '?' + queryParams : ''}`;
|
||||
const response = await fetchWithTimeout(url, API_TIMEOUT, signal);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Failed to fetch matrix');
|
||||
}
|
||||
|
||||
async function fetchHold(signal = null) {
|
||||
const queryParams = buildQueryParams();
|
||||
const url = `/api/wip/overview/hold${queryParams ? '?' + queryParams : ''}`;
|
||||
const response = await fetchWithTimeout(url, API_TIMEOUT, signal);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Failed to fetch hold');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Render Functions
|
||||
// ============================================================
|
||||
@@ -1337,5 +1308,4 @@
|
||||
startAutoRefresh();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
||||
35
tests/e2e/conftest.py
Normal file
35
tests/e2e/conftest.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Pytest configuration for Playwright E2E tests."""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add src to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'src'))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def app_server() -> str:
|
||||
"""Get the base URL for E2E testing.
|
||||
|
||||
Uses environment variable E2E_BASE_URL or defaults to production server.
|
||||
"""
|
||||
return os.environ.get('E2E_BASE_URL', 'http://127.0.0.1:5000')
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser_context_args(browser_context_args):
|
||||
"""Configure browser context for tests."""
|
||||
return {
|
||||
**browser_context_args,
|
||||
"viewport": {"width": 1280, "height": 720},
|
||||
"locale": "zh-TW",
|
||||
}
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Add custom markers for E2E tests."""
|
||||
config.addinivalue_line(
|
||||
"markers", "e2e: mark test as end-to-end test (requires running server)"
|
||||
)
|
||||
362
tests/e2e/test_global_connection.py
Normal file
362
tests/e2e/test_global_connection.py
Normal file
@@ -0,0 +1,362 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""E2E tests for global connection management features.
|
||||
|
||||
Tests the MesApi client, Toast notifications, and page functionality
|
||||
using Playwright.
|
||||
|
||||
Run with: pytest tests/e2e/ --headed (to see browser)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import re
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestPortalPage:
|
||||
"""E2E tests for the Portal page."""
|
||||
|
||||
def test_portal_loads_successfully(self, page: Page, app_server: str):
|
||||
"""Portal page should load without errors."""
|
||||
page.goto(app_server)
|
||||
|
||||
# Wait for page to load
|
||||
expect(page.locator('h1')).to_contain_text('MES 報表入口')
|
||||
|
||||
def test_portal_has_all_tabs(self, page: Page, app_server: str):
|
||||
"""Portal should have all navigation tabs."""
|
||||
page.goto(app_server)
|
||||
|
||||
# Check all tabs exist
|
||||
expect(page.locator('.tab:has-text("WIP 即時概況")')).to_be_visible()
|
||||
expect(page.locator('.tab:has-text("機台狀態報表")')).to_be_visible()
|
||||
expect(page.locator('.tab:has-text("數據表查詢工具")')).to_be_visible()
|
||||
expect(page.locator('.tab:has-text("Excel 批次查詢")')).to_be_visible()
|
||||
|
||||
def test_portal_tab_switching(self, page: Page, app_server: str):
|
||||
"""Portal tabs should switch iframe content."""
|
||||
page.goto(app_server)
|
||||
|
||||
# Click on a different tab
|
||||
page.locator('.tab:has-text("機台狀態報表")').click()
|
||||
|
||||
# Verify the tab is active
|
||||
expect(page.locator('.tab:has-text("機台狀態報表")')).to_have_class(re.compile(r'active'))
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestToastNotifications:
|
||||
"""E2E tests for Toast notification system."""
|
||||
|
||||
def test_toast_container_exists(self, page: Page, app_server: str):
|
||||
"""Toast container should be present in the DOM."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
# Toast container should exist in DOM (hidden when empty, which is expected)
|
||||
page.wait_for_selector('#mes-toast-container', state='attached', timeout=5000)
|
||||
|
||||
def test_toast_info_display(self, page: Page, app_server: str):
|
||||
"""Toast.info() should display info notification."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
# Execute Toast.info() in browser context
|
||||
page.evaluate("Toast.info('Test info message')")
|
||||
|
||||
# Verify toast appears
|
||||
toast = page.locator('.mes-toast-info')
|
||||
expect(toast).to_be_visible()
|
||||
expect(toast).to_contain_text('Test info message')
|
||||
|
||||
def test_toast_success_display(self, page: Page, app_server: str):
|
||||
"""Toast.success() should display success notification."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
page.evaluate("Toast.success('Operation successful')")
|
||||
|
||||
toast = page.locator('.mes-toast-success')
|
||||
expect(toast).to_be_visible()
|
||||
expect(toast).to_contain_text('Operation successful')
|
||||
|
||||
def test_toast_error_display(self, page: Page, app_server: str):
|
||||
"""Toast.error() should display error notification."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
page.evaluate("Toast.error('An error occurred')")
|
||||
|
||||
toast = page.locator('.mes-toast-error')
|
||||
expect(toast).to_be_visible()
|
||||
expect(toast).to_contain_text('An error occurred')
|
||||
|
||||
def test_toast_error_with_retry(self, page: Page, app_server: str):
|
||||
"""Toast.error() with retry callback should show retry button."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
page.evaluate("Toast.error('Connection failed', { retry: () => console.log('retry clicked') })")
|
||||
|
||||
# Verify retry button exists
|
||||
retry_btn = page.locator('.mes-toast-retry')
|
||||
expect(retry_btn).to_be_visible()
|
||||
expect(retry_btn).to_contain_text('重試')
|
||||
|
||||
def test_toast_loading_display(self, page: Page, app_server: str):
|
||||
"""Toast.loading() should display loading notification."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
page.evaluate("Toast.loading('Loading data...')")
|
||||
|
||||
toast = page.locator('.mes-toast-loading')
|
||||
expect(toast).to_be_visible()
|
||||
|
||||
def test_toast_dismiss(self, page: Page, app_server: str):
|
||||
"""Toast.dismiss() should remove toast."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
# Create and dismiss a toast
|
||||
toast_id = page.evaluate("Toast.info('Will be dismissed')")
|
||||
page.evaluate(f"Toast.dismiss({toast_id})")
|
||||
|
||||
# Wait for animation
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# Toast should be gone
|
||||
expect(page.locator('.mes-toast-info')).not_to_be_visible()
|
||||
|
||||
def test_toast_max_limit(self, page: Page, app_server: str):
|
||||
"""Toast system should enforce max 5 toasts."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
# Create 7 toasts
|
||||
for i in range(7):
|
||||
page.evaluate(f"Toast.info('Toast {i}')")
|
||||
|
||||
# Should only have 5 toasts visible
|
||||
toasts = page.locator('.mes-toast')
|
||||
expect(toasts).to_have_count(5)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestMesApiClient:
|
||||
"""E2E tests for MesApi client."""
|
||||
|
||||
def test_mesapi_exists_on_page(self, page: Page, app_server: str):
|
||||
"""MesApi should be available in window scope."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
has_mesapi = page.evaluate("typeof MesApi !== 'undefined'")
|
||||
assert has_mesapi, "MesApi should be defined"
|
||||
|
||||
def test_mesapi_has_get_method(self, page: Page, app_server: str):
|
||||
"""MesApi should have get() method."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
has_get = page.evaluate("typeof MesApi.get === 'function'")
|
||||
assert has_get, "MesApi.get should be a function"
|
||||
|
||||
def test_mesapi_has_post_method(self, page: Page, app_server: str):
|
||||
"""MesApi should have post() method."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
has_post = page.evaluate("typeof MesApi.post === 'function'")
|
||||
assert has_post, "MesApi.post should be a function"
|
||||
|
||||
def test_mesapi_request_logging(self, page: Page, app_server: str):
|
||||
"""MesApi should log requests to console."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
# Capture console messages
|
||||
console_messages = []
|
||||
page.on("console", lambda msg: console_messages.append(msg.text))
|
||||
|
||||
# Make a request (will fail but should log)
|
||||
page.evaluate("""
|
||||
(async () => {
|
||||
try {
|
||||
await MesApi.get('/api/test-endpoint');
|
||||
} catch (e) {
|
||||
// Expected to fail
|
||||
}
|
||||
})()
|
||||
""")
|
||||
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# Check for MesApi log pattern
|
||||
mesapi_logs = [m for m in console_messages if '[MesApi]' in m]
|
||||
assert len(mesapi_logs) > 0, "MesApi should log requests with [MesApi] prefix"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestWIPOverviewPage:
|
||||
"""E2E tests for WIP Overview page."""
|
||||
|
||||
def test_wip_overview_loads(self, page: Page, app_server: str):
|
||||
"""WIP Overview page should load."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
# Page should have the header
|
||||
expect(page.locator('body')).to_be_visible()
|
||||
|
||||
def test_wip_overview_has_toast_system(self, page: Page, app_server: str):
|
||||
"""WIP Overview should have Toast system loaded."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
has_toast = page.evaluate("typeof Toast !== 'undefined'")
|
||||
assert has_toast, "Toast should be defined on WIP Overview page"
|
||||
|
||||
def test_wip_overview_has_mesapi(self, page: Page, app_server: str):
|
||||
"""WIP Overview should have MesApi loaded."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
has_mesapi = page.evaluate("typeof MesApi !== 'undefined'")
|
||||
assert has_mesapi, "MesApi should be defined on WIP Overview page"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestWIPDetailPage:
|
||||
"""E2E tests for WIP Detail page."""
|
||||
|
||||
def test_wip_detail_loads(self, page: Page, app_server: str):
|
||||
"""WIP Detail page should load."""
|
||||
page.goto(f"{app_server}/wip-detail")
|
||||
|
||||
expect(page.locator('body')).to_be_visible()
|
||||
|
||||
def test_wip_detail_has_toast_system(self, page: Page, app_server: str):
|
||||
"""WIP Detail should have Toast system loaded."""
|
||||
page.goto(f"{app_server}/wip-detail")
|
||||
|
||||
has_toast = page.evaluate("typeof Toast !== 'undefined'")
|
||||
assert has_toast, "Toast should be defined on WIP Detail page"
|
||||
|
||||
def test_wip_detail_has_mesapi(self, page: Page, app_server: str):
|
||||
"""WIP Detail should have MesApi loaded."""
|
||||
page.goto(f"{app_server}/wip-detail")
|
||||
|
||||
has_mesapi = page.evaluate("typeof MesApi !== 'undefined'")
|
||||
assert has_mesapi, "MesApi should be defined on WIP Detail page"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestTablesPage:
|
||||
"""E2E tests for Tables page."""
|
||||
|
||||
def test_tables_page_loads(self, page: Page, app_server: str):
|
||||
"""Tables page should load."""
|
||||
page.goto(f"{app_server}/tables")
|
||||
|
||||
expect(page.locator('h1')).to_contain_text('MES 數據表查詢工具')
|
||||
|
||||
def test_tables_has_toast_system(self, page: Page, app_server: str):
|
||||
"""Tables page should have Toast system loaded."""
|
||||
page.goto(f"{app_server}/tables")
|
||||
|
||||
has_toast = page.evaluate("typeof Toast !== 'undefined'")
|
||||
assert has_toast, "Toast should be defined on Tables page"
|
||||
|
||||
def test_tables_has_mesapi(self, page: Page, app_server: str):
|
||||
"""Tables page should have MesApi loaded."""
|
||||
page.goto(f"{app_server}/tables")
|
||||
|
||||
has_mesapi = page.evaluate("typeof MesApi !== 'undefined'")
|
||||
assert has_mesapi, "MesApi should be defined on Tables page"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestResourcePage:
|
||||
"""E2E tests for Resource Status page."""
|
||||
|
||||
def test_resource_page_loads(self, page: Page, app_server: str):
|
||||
"""Resource page should load."""
|
||||
page.goto(f"{app_server}/resource")
|
||||
|
||||
expect(page.locator('body')).to_be_visible()
|
||||
|
||||
def test_resource_has_toast_system(self, page: Page, app_server: str):
|
||||
"""Resource page should have Toast system loaded."""
|
||||
page.goto(f"{app_server}/resource")
|
||||
|
||||
has_toast = page.evaluate("typeof Toast !== 'undefined'")
|
||||
assert has_toast, "Toast should be defined on Resource page"
|
||||
|
||||
def test_resource_has_mesapi(self, page: Page, app_server: str):
|
||||
"""Resource page should have MesApi loaded."""
|
||||
page.goto(f"{app_server}/resource")
|
||||
|
||||
has_mesapi = page.evaluate("typeof MesApi !== 'undefined'")
|
||||
assert has_mesapi, "MesApi should be defined on Resource page"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestExcelQueryPage:
|
||||
"""E2E tests for Excel Query page."""
|
||||
|
||||
def test_excel_query_page_loads(self, page: Page, app_server: str):
|
||||
"""Excel Query page should load."""
|
||||
page.goto(f"{app_server}/excel-query")
|
||||
|
||||
expect(page.locator('body')).to_be_visible()
|
||||
|
||||
def test_excel_query_has_toast_system(self, page: Page, app_server: str):
|
||||
"""Excel Query page should have Toast system loaded."""
|
||||
page.goto(f"{app_server}/excel-query")
|
||||
|
||||
has_toast = page.evaluate("typeof Toast !== 'undefined'")
|
||||
assert has_toast, "Toast should be defined on Excel Query page"
|
||||
|
||||
def test_excel_query_has_mesapi(self, page: Page, app_server: str):
|
||||
"""Excel Query page should have MesApi loaded."""
|
||||
page.goto(f"{app_server}/excel-query")
|
||||
|
||||
has_mesapi = page.evaluate("typeof MesApi !== 'undefined'")
|
||||
assert has_mesapi, "MesApi should be defined on Excel Query page"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestConsoleLogVerification:
|
||||
"""E2E tests for console log verification (Phase 4.2 tasks)."""
|
||||
|
||||
def test_request_has_request_id(self, page: Page, app_server: str):
|
||||
"""API requests should log with req_xxx ID format."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
console_messages = []
|
||||
page.on("console", lambda msg: console_messages.append(msg.text))
|
||||
|
||||
# Trigger an API request
|
||||
page.evaluate("""
|
||||
(async () => {
|
||||
try {
|
||||
await MesApi.get('/api/wip/overview/summary');
|
||||
} catch (e) {}
|
||||
})()
|
||||
""")
|
||||
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# Check for request ID pattern
|
||||
req_id_pattern = re.compile(r'req_\d{4}')
|
||||
has_req_id = any(req_id_pattern.search(m) for m in console_messages)
|
||||
assert has_req_id, "Console should show request ID like req_0001"
|
||||
|
||||
def test_successful_request_shows_checkmark(self, page: Page, app_server: str):
|
||||
"""Successful requests should show checkmark in console."""
|
||||
page.goto(f"{app_server}/wip-overview")
|
||||
|
||||
console_messages = []
|
||||
page.on("console", lambda msg: console_messages.append(msg.text))
|
||||
|
||||
# Make request to a working endpoint
|
||||
page.evaluate("""
|
||||
(async () => {
|
||||
try {
|
||||
await MesApi.get('/api/wip/overview/summary');
|
||||
} catch (e) {}
|
||||
})()
|
||||
""")
|
||||
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
# Filter for MesApi logs
|
||||
mesapi_logs = [m for m in console_messages if '[MesApi]' in m]
|
||||
# The exact checkmark depends on implementation (✓ or similar)
|
||||
assert len(mesapi_logs) > 0, "Should have MesApi console logs"
|
||||
284
tests/test_api_integration.py
Normal file
284
tests/test_api_integration.py
Normal file
@@ -0,0 +1,284 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Integration tests for API endpoints.
|
||||
|
||||
Tests API endpoints for proper response format, error handling,
|
||||
and timeout behavior compatible with the MesApi client.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import json
|
||||
|
||||
from mes_dashboard.app import create_app
|
||||
import mes_dashboard.core.database as db
|
||||
|
||||
|
||||
class TestTableQueryAPIIntegration(unittest.TestCase):
|
||||
"""Integration tests for table query APIs."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
db._ENGINE = None
|
||||
self.app = create_app('testing')
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
|
||||
@patch('mes_dashboard.app.get_table_columns')
|
||||
def test_get_table_columns_success(self, mock_get_columns):
|
||||
"""GET table columns should return JSON with columns array."""
|
||||
mock_get_columns.return_value = ['ID', 'NAME', 'STATUS', 'CREATED_AT']
|
||||
|
||||
response = self.client.post(
|
||||
'/api/get_table_columns',
|
||||
json={'table_name': 'TEST_TABLE'},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertIn('columns', data)
|
||||
self.assertEqual(len(data['columns']), 4)
|
||||
|
||||
def test_get_table_columns_missing_table_name(self):
|
||||
"""GET table columns without table_name should return 400."""
|
||||
response = self.client.post(
|
||||
'/api/get_table_columns',
|
||||
json={},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
data = json.loads(response.data)
|
||||
self.assertIn('error', data)
|
||||
|
||||
@patch('mes_dashboard.app.get_table_data')
|
||||
def test_query_table_success(self, mock_get_data):
|
||||
"""Query table should return JSON with data array."""
|
||||
mock_get_data.return_value = {
|
||||
'data': [{'ID': 1, 'NAME': 'Test'}],
|
||||
'row_count': 1
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
'/api/query_table',
|
||||
json={'table_name': 'TEST_TABLE', 'limit': 100},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertIn('data', data)
|
||||
self.assertEqual(data['row_count'], 1)
|
||||
|
||||
def test_query_table_missing_table_name(self):
|
||||
"""Query table without table_name should return 400."""
|
||||
response = self.client.post(
|
||||
'/api/query_table',
|
||||
json={'limit': 100},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
data = json.loads(response.data)
|
||||
self.assertIn('error', data)
|
||||
|
||||
@patch('mes_dashboard.app.get_table_data')
|
||||
def test_query_table_with_filters(self, mock_get_data):
|
||||
"""Query table should pass filters to the service."""
|
||||
mock_get_data.return_value = {
|
||||
'data': [],
|
||||
'row_count': 0
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
'/api/query_table',
|
||||
json={
|
||||
'table_name': 'TEST_TABLE',
|
||||
'limit': 100,
|
||||
'filters': {'STATUS': 'ACTIVE'}
|
||||
},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
mock_get_data.assert_called_once()
|
||||
call_args = mock_get_data.call_args
|
||||
self.assertEqual(call_args[0][3], {'STATUS': 'ACTIVE'})
|
||||
|
||||
|
||||
class TestWIPAPIIntegration(unittest.TestCase):
|
||||
"""Integration tests for WIP API endpoints."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
db._ENGINE = None
|
||||
self.app = create_app('testing')
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
|
||||
@patch('mes_dashboard.routes.wip_routes.get_wip_summary')
|
||||
def test_wip_summary_response_format(self, mock_summary):
|
||||
"""WIP summary should return consistent JSON structure."""
|
||||
mock_summary.return_value = {
|
||||
'totalLots': 1000,
|
||||
'totalQtyPcs': 100000,
|
||||
'byWipStatus': {
|
||||
'run': {'lots': 800, 'qtyPcs': 80000},
|
||||
'queue': {'lots': 150, 'qtyPcs': 15000},
|
||||
'hold': {'lots': 50, 'qtyPcs': 5000}
|
||||
},
|
||||
'dataUpdateDate': '2026-01-28 10:00:00'
|
||||
}
|
||||
|
||||
response = self.client.get('/api/wip/overview/summary')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
|
||||
# Verify response structure for MesApi compatibility
|
||||
self.assertIn('success', data)
|
||||
self.assertTrue(data['success'])
|
||||
self.assertIn('data', data)
|
||||
|
||||
@patch('mes_dashboard.routes.wip_routes.get_wip_summary')
|
||||
def test_wip_summary_error_response(self, mock_summary):
|
||||
"""WIP summary error should return proper error structure."""
|
||||
mock_summary.return_value = None
|
||||
|
||||
response = self.client.get('/api/wip/overview/summary')
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
data = json.loads(response.data)
|
||||
|
||||
# Verify error response structure
|
||||
self.assertIn('success', data)
|
||||
self.assertFalse(data['success'])
|
||||
self.assertIn('error', data)
|
||||
|
||||
@patch('mes_dashboard.routes.wip_routes.get_wip_matrix')
|
||||
def test_wip_matrix_response_format(self, mock_matrix):
|
||||
"""WIP matrix should return consistent JSON structure."""
|
||||
mock_matrix.return_value = {
|
||||
'workcenters': ['WC1', 'WC2'],
|
||||
'packages': ['PKG1'],
|
||||
'matrix': {'WC1': {'PKG1': 100}},
|
||||
'workcenter_totals': {'WC1': 100},
|
||||
'package_totals': {'PKG1': 100},
|
||||
'grand_total': 100
|
||||
}
|
||||
|
||||
response = self.client.get('/api/wip/overview/matrix')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
|
||||
self.assertIn('success', data)
|
||||
self.assertTrue(data['success'])
|
||||
self.assertIn('data', data)
|
||||
self.assertIn('workcenters', data['data'])
|
||||
self.assertIn('matrix', data['data'])
|
||||
|
||||
@patch('mes_dashboard.routes.wip_routes.get_wip_detail')
|
||||
def test_wip_detail_response_format(self, mock_detail):
|
||||
"""WIP detail should return consistent JSON structure."""
|
||||
mock_detail.return_value = {
|
||||
'workcenter': 'TestWC',
|
||||
'summary': {
|
||||
'total_lots': 100,
|
||||
'on_equipment_lots': 50,
|
||||
'waiting_lots': 40,
|
||||
'hold_lots': 10
|
||||
},
|
||||
'specs': ['Spec1'],
|
||||
'lots': [{'lot_id': 'LOT001', 'status': 'ACTIVE'}],
|
||||
'pagination': {
|
||||
'page': 1,
|
||||
'page_size': 100,
|
||||
'total_count': 100,
|
||||
'total_pages': 1
|
||||
},
|
||||
'sys_date': '2026-01-28 10:00:00'
|
||||
}
|
||||
|
||||
response = self.client.get('/api/wip/detail/TestWC')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
|
||||
self.assertIn('success', data)
|
||||
self.assertTrue(data['success'])
|
||||
self.assertIn('data', data)
|
||||
self.assertIn('pagination', data['data'])
|
||||
|
||||
|
||||
class TestResourceAPIIntegration(unittest.TestCase):
|
||||
"""Integration tests for Resource API endpoints."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
db._ENGINE = None
|
||||
self.app = create_app('testing')
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
|
||||
@patch('mes_dashboard.routes.resource_routes.query_resource_status_summary')
|
||||
def test_resource_summary_response_format(self, mock_summary):
|
||||
"""Resource summary should return consistent JSON structure."""
|
||||
mock_summary.return_value = {
|
||||
'total_equipment': 100,
|
||||
'by_status': {
|
||||
'RUN': 60,
|
||||
'IDLE': 30,
|
||||
'DOWN': 10
|
||||
}
|
||||
}
|
||||
|
||||
response = self.client.get('/api/resource/summary')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
|
||||
# Verify response structure
|
||||
self.assertIn('success', data)
|
||||
self.assertTrue(data['success'])
|
||||
|
||||
|
||||
class TestAPIContentType(unittest.TestCase):
|
||||
"""Test that APIs return proper content types."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
db._ENGINE = None
|
||||
self.app = create_app('testing')
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
|
||||
@patch('mes_dashboard.routes.wip_routes.get_wip_summary')
|
||||
def test_api_returns_json_content_type(self, mock_summary):
|
||||
"""API endpoints should return application/json content type."""
|
||||
mock_summary.return_value = {
|
||||
'totalLots': 0, 'totalQtyPcs': 0,
|
||||
'byWipStatus': {'run': {}, 'queue': {}, 'hold': {}},
|
||||
'dataUpdateDate': None
|
||||
}
|
||||
|
||||
response = self.client.get('/api/wip/overview/summary')
|
||||
|
||||
self.assertIn('application/json', response.content_type)
|
||||
|
||||
@patch('mes_dashboard.app.get_table_columns')
|
||||
def test_table_api_returns_json_content_type(self, mock_columns):
|
||||
"""Table API should return application/json content type."""
|
||||
mock_columns.return_value = ['COL1', 'COL2']
|
||||
|
||||
response = self.client.post(
|
||||
'/api/get_table_columns',
|
||||
json={'table_name': 'TEST'},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
self.assertIn('application/json', response.content_type)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
219
tests/test_template_integration.py
Normal file
219
tests/test_template_integration.py
Normal file
@@ -0,0 +1,219 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for template integration with _base.html.
|
||||
|
||||
Verifies that all templates properly extend _base.html and include
|
||||
the required MesApi and Toast JavaScript modules.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from mes_dashboard.app import create_app
|
||||
import mes_dashboard.core.database as db
|
||||
|
||||
|
||||
class TestTemplateIntegration(unittest.TestCase):
|
||||
"""Test that all templates properly extend _base.html."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
db._ENGINE = None
|
||||
self.app = create_app('testing')
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def test_portal_includes_base_scripts(self):
|
||||
"""Portal page should include toast.js and mes-api.js."""
|
||||
response = self.client.get('/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
self.assertIn('toast.js', html)
|
||||
self.assertIn('mes-api.js', html)
|
||||
self.assertIn('mes-toast-container', html)
|
||||
|
||||
def test_wip_overview_includes_base_scripts(self):
|
||||
"""WIP Overview page should include toast.js and mes-api.js."""
|
||||
response = self.client.get('/wip-overview')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
self.assertIn('toast.js', html)
|
||||
self.assertIn('mes-api.js', html)
|
||||
self.assertIn('mes-toast-container', html)
|
||||
|
||||
def test_wip_detail_includes_base_scripts(self):
|
||||
"""WIP Detail page should include toast.js and mes-api.js."""
|
||||
response = self.client.get('/wip-detail')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
self.assertIn('toast.js', html)
|
||||
self.assertIn('mes-api.js', html)
|
||||
self.assertIn('mes-toast-container', html)
|
||||
|
||||
def test_tables_page_includes_base_scripts(self):
|
||||
"""Tables page should include toast.js and mes-api.js."""
|
||||
response = self.client.get('/tables')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
self.assertIn('toast.js', html)
|
||||
self.assertIn('mes-api.js', html)
|
||||
self.assertIn('mes-toast-container', html)
|
||||
|
||||
def test_resource_page_includes_base_scripts(self):
|
||||
"""Resource status page should include toast.js and mes-api.js."""
|
||||
response = self.client.get('/resource')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
self.assertIn('toast.js', html)
|
||||
self.assertIn('mes-api.js', html)
|
||||
self.assertIn('mes-toast-container', html)
|
||||
|
||||
def test_excel_query_page_includes_base_scripts(self):
|
||||
"""Excel Query page should include toast.js and mes-api.js."""
|
||||
response = self.client.get('/excel-query')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
self.assertIn('toast.js', html)
|
||||
self.assertIn('mes-api.js', html)
|
||||
self.assertIn('mes-toast-container', html)
|
||||
|
||||
|
||||
class TestToastCSSIntegration(unittest.TestCase):
|
||||
"""Test that Toast CSS styles are included in all pages."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
db._ENGINE = None
|
||||
self.app = create_app('testing')
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def test_portal_includes_toast_css(self):
|
||||
"""Portal page should include Toast CSS styles."""
|
||||
response = self.client.get('/')
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
# Check for Toast CSS class definitions
|
||||
self.assertIn('.mes-toast-container', html)
|
||||
self.assertIn('.mes-toast', html)
|
||||
|
||||
def test_wip_overview_includes_toast_css(self):
|
||||
"""WIP Overview page should include Toast CSS styles."""
|
||||
response = self.client.get('/wip-overview')
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
self.assertIn('.mes-toast-container', html)
|
||||
self.assertIn('.mes-toast', html)
|
||||
|
||||
def test_wip_detail_includes_toast_css(self):
|
||||
"""WIP Detail page should include Toast CSS styles."""
|
||||
response = self.client.get('/wip-detail')
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
self.assertIn('.mes-toast-container', html)
|
||||
self.assertIn('.mes-toast', html)
|
||||
|
||||
|
||||
class TestMesApiUsageInTemplates(unittest.TestCase):
|
||||
"""Test that templates use MesApi for API calls."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
db._ENGINE = None
|
||||
self.app = create_app('testing')
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def test_wip_overview_uses_mesapi(self):
|
||||
"""WIP Overview should use MesApi.get() for API calls."""
|
||||
response = self.client.get('/wip-overview')
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
self.assertIn('MesApi.get', html)
|
||||
# Should NOT contain raw fetch() for API calls
|
||||
# (checking it doesn't have the old fetchWithTimeout pattern)
|
||||
self.assertNotIn('fetchWithTimeout', html)
|
||||
|
||||
def test_wip_detail_uses_mesapi(self):
|
||||
"""WIP Detail should use MesApi.get() for API calls."""
|
||||
response = self.client.get('/wip-detail')
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
self.assertIn('MesApi.get', html)
|
||||
self.assertNotIn('fetchWithTimeout', html)
|
||||
|
||||
def test_tables_page_uses_mesapi(self):
|
||||
"""Tables page should use MesApi.post() for API calls."""
|
||||
response = self.client.get('/tables')
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
self.assertIn('MesApi.post', html)
|
||||
|
||||
def test_resource_page_uses_mesapi(self):
|
||||
"""Resource status page should use MesApi.post() for API calls."""
|
||||
response = self.client.get('/resource')
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
self.assertIn('MesApi.post', html)
|
||||
|
||||
|
||||
class TestStaticFilesServing(unittest.TestCase):
|
||||
"""Test that static JavaScript files are served correctly."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
db._ENGINE = None
|
||||
self.app = create_app('testing')
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def test_toast_js_is_served(self):
|
||||
"""toast.js should be served from static directory."""
|
||||
response = self.client.get('/static/js/toast.js')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
content = response.data.decode('utf-8')
|
||||
|
||||
# Verify it's the Toast module
|
||||
self.assertIn('Toast', content)
|
||||
self.assertIn('info', content)
|
||||
self.assertIn('success', content)
|
||||
self.assertIn('error', content)
|
||||
self.assertIn('loading', content)
|
||||
|
||||
def test_mes_api_js_is_served(self):
|
||||
"""mes-api.js should be served from static directory."""
|
||||
response = self.client.get('/static/js/mes-api.js')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
content = response.data.decode('utf-8')
|
||||
|
||||
# Verify it's the MesApi module
|
||||
self.assertIn('MesApi', content)
|
||||
self.assertIn('get', content)
|
||||
self.assertIn('post', content)
|
||||
self.assertIn('AbortController', content)
|
||||
|
||||
def test_toast_js_contains_retry_button(self):
|
||||
"""toast.js should support retry button for errors."""
|
||||
response = self.client.get('/static/js/toast.js')
|
||||
content = response.data.decode('utf-8')
|
||||
|
||||
self.assertIn('retry', content)
|
||||
self.assertIn('mes-toast-retry', content)
|
||||
|
||||
def test_mes_api_js_has_exponential_backoff(self):
|
||||
"""mes-api.js should implement exponential backoff."""
|
||||
response = self.client.get('/static/js/mes-api.js')
|
||||
content = response.data.decode('utf-8')
|
||||
|
||||
# Check for retry delay calculation (1000, 2000, 4000)
|
||||
self.assertIn('1000', content)
|
||||
self.assertIn('retry', content.lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user