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:
beabigegg
2026-01-28 08:04:52 +08:00
parent 42dd06b2c0
commit a06faa7c83
24 changed files with 3397 additions and 451 deletions

View File

@@ -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. 手動測試場景
| 場景 | 測試方法 | 預期結果 |
|------|----------|----------|
| 正常請求 | 正常操作 | 無 toastconsole 顯示 ✓ |
| Timeout | 後端加 `time.sleep(35)` | 顯示重試 toast最終失敗 |
| 5xx 錯誤 | 後端回傳 500 | 顯示重試 toast |
| 4xx 錯誤 | 錯誤參數 | 直接顯示錯誤,無重試 |
| 請求取消 | 快速換頁 | 無 toastconsole 顯示 ⊘ |
| 手動重試 | 點擊重試按鈕 | 重新發送請求 |
### 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 stylesToast 需要):
```html
<meta http-equiv="Content-Security-Policy" content="style-src 'self' 'unsafe-inline';">
```

View File

@@ -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 Retry1s → 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 保持不變

View 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`

View 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`

View 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()` 顯示藍色 Toast3 秒後自動消失
- [ ] `Toast.success()` 顯示綠色 Toast2 秒後自動消失
- [ ] `Toast.warning()` 顯示橙色 Toast5 秒後自動消失
- [ ] `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>` 區塊中,確保所有頁面都有樣式定義。

View File

@@ -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` logE2E 測試驗證)
- [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 個自動化測試全部通過

View 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`

View 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`

View 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()` 顯示藍色 Toast3 秒後自動消失
- [ ] `Toast.success()` 顯示綠色 Toast2 秒後自動消失
- [ ] `Toast.warning()` 顯示橙色 Toast5 秒後自動消失
- [ ] `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>` 區塊中,確保所有頁面都有樣式定義。

View File

@@ -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
View 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)

View 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 });
}
};
})();

View 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 = '&times;';
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
};
})();

View 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>

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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
View 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)"
)

View 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"

View 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()

View 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()