feat(qc-gate): add QC-GATE real-time LOT status report as first pure Vue 3 + Vite page
Introduce QC-GATE station monitoring with stacked bar chart and filterable LOT table, using Vue 3 SFC + ECharts via npm. Establishes the pure Vite page architecture pattern (no Jinja2) for future page migration. Also removes stale design files and README.mdj. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
153
README.mdj
153
README.mdj
@@ -1,153 +0,0 @@
|
||||
# MES Dashboard(README.mdj)
|
||||
|
||||
本文件為 `README.md` 的精簡技術同步版,聚焦目前可運行架構與運維契約。
|
||||
|
||||
## 1. 架構摘要(2026-02-08)
|
||||
|
||||
- 後端:Flask + Gunicorn(單一 port)
|
||||
- 前端:Vite build 輸出到 `src/mes_dashboard/static/dist`
|
||||
- 快取:Redis + process-level cache + indexed selection telemetry
|
||||
- 資料:Oracle(QueuePool)
|
||||
- 運維:watchdog + admin worker restart API + guarded-mode policy
|
||||
- 環境設定:開發與正式環境統一使用專案根目錄同一份 `.env`
|
||||
- 啟動腳本:`./scripts/start_server.sh start` 會同時啟動 Gunicorn 與 `worker_watchdog.py`
|
||||
|
||||
## 2. 既有設計原則(保留)
|
||||
|
||||
- `resource`(設備基礎資料)與 `wip`(線上即時狀況)維持全表快取策略。
|
||||
- 前端頁面邏輯與 drill-down 操作語意維持不變。
|
||||
- 系統維持單一 port 服務模式(前後端同源)。
|
||||
|
||||
## 3. P0 Runtime Hardening(已完成)
|
||||
|
||||
- Production 強制 `SECRET_KEY`:未設定或使用不安全預設值時,啟動直接失敗。
|
||||
- CSRF 防護:
|
||||
- `/admin/login` 表單需 token
|
||||
- `/admin/api/*` 的 `POST/PUT/PATCH/DELETE` 需 `X-CSRF-Token`
|
||||
- Session hardening:登入成功後 `session.clear()` + CSRF token rotation。
|
||||
- Health probe isolation:`/health` DB 連通檢查使用獨立 health pool。
|
||||
- Shutdown cleanup:統一停止 cache updater、equipment sync worker,並關閉 Redis 與 DB engine。
|
||||
- XSS hardening:`hold_detail` fallback script 的 `reason` 改用 `tojson`。
|
||||
|
||||
## 4. P1 Cache/Query Efficiency(已完成)
|
||||
|
||||
- `resource` / `wip` 仍維持全表快取策略(業務約束不變)。
|
||||
- WIP 查詢改走 indexed selection,並加入增量同步(watermark/version)與 drift fallback。
|
||||
- `/health`、`/health/deep`、`/admin/api/system-status` 提供 cache memory amplification/index telemetry。
|
||||
- 新增 benchmark harness:`scripts/run_cache_benchmarks.py --enforce`。
|
||||
|
||||
## 5. P2 Ops Self-Healing(已完成)
|
||||
|
||||
- runtime contract 共用化:app/start_server/watchdog/systemd 使用同一組 watchdog/conda 路徑契約。
|
||||
- 啟動 fail-fast:conda/runtime path drift 時拒絕啟動並輸出可操作診斷。
|
||||
- worker restart policy:cooldown + retry budget + churn guarded mode。
|
||||
- manual override:需 admin 身分 + `manual_override` + `override_acknowledged` + `override_reason`,且寫入 audit log。
|
||||
- health/admin payload 提供 policy state:`allowed` / `cooldown` / `blocked`。
|
||||
|
||||
## 6. Round-3 Residual Hardening(已完成)
|
||||
|
||||
- WIP cache publish 改為 staged publish,更新失敗不覆寫舊快照。
|
||||
- WIP process cache slow-path parse 移到鎖外,降低 lock contention。
|
||||
- realtime equipment process cache 補齊 bounded LRU(含 `EQUIPMENT_PROCESS_CACHE_MAX_SIZE`)。
|
||||
- `_clean_nan_values` 改為 depth-safe 迭代式清理(避免深層遞迴風險)。
|
||||
- WIP/Hold/Resource bool query parser 共用化(`core/utils.py`)。
|
||||
- filter cache source view 可由 env 覆寫(便於環境切換與測試)。
|
||||
- `/health`、`/health/deep` 增加 5 秒 memo(testing 模式自動關閉)。
|
||||
- 高成本 API 增加輕量 in-process rate limit,超限回傳一致 429 結構。
|
||||
- DB 連線字串記錄加上敏感欄位遮罩(密碼 redaction)。
|
||||
|
||||
## 7. Round-4 Residual Consolidation(已完成)
|
||||
|
||||
- Resource derived index 改為 row-position representation,不再在 process 內保存 full records 複本。
|
||||
- Resource / Realtime Equipment 共用 Oracle SQL fragments,避免查詢定義重複漂移。
|
||||
- `resource_cache` / `realtime_equipment_cache` 型別註記風格與高頻常數命名收斂。
|
||||
- `page_registry` 寫檔改為 atomic replace,降低設定檔半寫入風險。
|
||||
- 新增測試覆蓋 shared SQL fragment 與 bool parser 不重複定義治理。
|
||||
|
||||
## 8. 重要環境變數
|
||||
|
||||
```bash
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=<required-in-production>
|
||||
CSRF_ENABLED=true
|
||||
|
||||
LDAP_API_URL=https://ldap-api.example.com
|
||||
LDAP_ALLOWED_HOSTS=ldap-api.example.com,ldap-api-dr.example.com
|
||||
|
||||
DB_POOL_SIZE=10
|
||||
DB_MAX_OVERFLOW=20
|
||||
DB_POOL_TIMEOUT=30
|
||||
DB_POOL_RECYCLE=1800
|
||||
DB_CALL_TIMEOUT_MS=55000
|
||||
DB_POOL_EXHAUSTED_RETRY_AFTER_SECONDS=5
|
||||
|
||||
DB_HEALTH_POOL_SIZE=1
|
||||
DB_HEALTH_MAX_OVERFLOW=0
|
||||
DB_HEALTH_POOL_TIMEOUT=2
|
||||
|
||||
CONDA_BIN=/opt/miniconda3/bin/conda
|
||||
CONDA_ENV_NAME=mes-dashboard
|
||||
RUNTIME_CONTRACT_VERSION=2026.02-p2
|
||||
RUNTIME_CONTRACT_ENFORCE=true
|
||||
|
||||
WATCHDOG_RUNTIME_DIR=./tmp
|
||||
WATCHDOG_RESTART_FLAG=./tmp/mes_dashboard_restart.flag
|
||||
WATCHDOG_PID_FILE=./tmp/gunicorn.pid
|
||||
WATCHDOG_STATE_FILE=./tmp/mes_dashboard_restart_state.json
|
||||
WATCHDOG_RESTART_HISTORY_MAX=50
|
||||
|
||||
WORKER_RESTART_COOLDOWN=60
|
||||
WORKER_RESTART_RETRY_BUDGET=3
|
||||
WORKER_RESTART_WINDOW_SECONDS=600
|
||||
WORKER_RESTART_CHURN_THRESHOLD=3
|
||||
WORKER_GUARDED_MODE_ENABLED=true
|
||||
|
||||
PROCESS_CACHE_MAX_SIZE=32
|
||||
WIP_PROCESS_CACHE_MAX_SIZE=32
|
||||
RESOURCE_PROCESS_CACHE_MAX_SIZE=32
|
||||
EQUIPMENT_PROCESS_CACHE_MAX_SIZE=32
|
||||
|
||||
FILTER_CACHE_WIP_VIEW=DWH.DW_MES_LOT_V
|
||||
FILTER_CACHE_SPEC_WORKCENTER_VIEW=DWH.DW_MES_SPEC_WORKCENTER_V
|
||||
|
||||
HEALTH_MEMO_TTL_SECONDS=5
|
||||
|
||||
WIP_MATRIX_RATE_LIMIT_MAX_REQUESTS=120
|
||||
WIP_MATRIX_RATE_LIMIT_WINDOW_SECONDS=60
|
||||
WIP_DETAIL_RATE_LIMIT_MAX_REQUESTS=90
|
||||
WIP_DETAIL_RATE_LIMIT_WINDOW_SECONDS=60
|
||||
HOLD_LOTS_RATE_LIMIT_MAX_REQUESTS=90
|
||||
HOLD_LOTS_RATE_LIMIT_WINDOW_SECONDS=60
|
||||
RESOURCE_DETAIL_RATE_LIMIT_MAX_REQUESTS=60
|
||||
RESOURCE_DETAIL_RATE_LIMIT_WINDOW_SECONDS=60
|
||||
RESOURCE_STATUS_RATE_LIMIT_MAX_REQUESTS=90
|
||||
RESOURCE_STATUS_RATE_LIMIT_WINDOW_SECONDS=60
|
||||
```
|
||||
|
||||
## 9. 驗證命令(建議)
|
||||
|
||||
```bash
|
||||
# 後端(conda)
|
||||
conda run -n mes-dashboard python -m pytest -q tests/test_runtime_hardening.py
|
||||
|
||||
# 前端
|
||||
npm --prefix frontend test
|
||||
npm --prefix frontend run build
|
||||
|
||||
# P1 benchmark gate
|
||||
conda run -n mes-dashboard python scripts/run_cache_benchmarks.py --enforce
|
||||
|
||||
# P2 runtime contract check
|
||||
RUNTIME_CONTRACT_ENFORCE=true ./scripts/start_server.sh check
|
||||
```
|
||||
|
||||
## 10. 開發歷史(Vite 專案)
|
||||
|
||||
- 2026-02-07:完成 Vite 根目錄重構與舊版切除。
|
||||
- 2026-02-08:完成 resilience 診斷治理與前端共用模組化。
|
||||
- 2026-02-08:完成 P0 安全/穩定性硬化(本次更新)。
|
||||
- 2026-02-08:完成 P1 快取查詢效率重構(index + benchmark gate)。
|
||||
- 2026-02-08:完成 P2 運維自癒治理(guarded mode + manual override + runtime contract)。
|
||||
- 2026-02-08:完成 round-2 hardening(LDAP URL 驗證、bounded LRU cache、circuit breaker 鎖外日誌、安全標頭、分頁邊界)。
|
||||
- 2026-02-08:完成 round-3 residual hardening(staged publish、health memo、API rate limit、DB redaction、filter view env 化)。
|
||||
- 2026-02-08:完成 round-4 residual consolidation(resource index 表示正規化、shared SQL fragments、型別與常數治理、atomic page status 寫入)。
|
||||
@@ -29,6 +29,13 @@
|
||||
"drawer_id": "reports",
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"route": "/qc-gate",
|
||||
"name": "QC-GATE 狀態",
|
||||
"status": "released",
|
||||
"drawer_id": "reports",
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"route": "/tables",
|
||||
"name": "表格總覽",
|
||||
|
||||
333
frontend/package-lock.json
generated
333
frontend/package-lock.json
generated
@@ -7,10 +7,62 @@
|
||||
"": {
|
||||
"name": "mes-dashboard-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"echarts": "^6.0.0",
|
||||
"vue": "^3.5.27",
|
||||
"vue-echarts": "^8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
||||
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
@@ -18,7 +70,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -35,7 +86,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -52,7 +102,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -69,7 +118,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -86,7 +134,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -103,7 +150,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -120,7 +166,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -137,7 +182,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -154,7 +198,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -171,7 +214,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -188,7 +230,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -205,7 +246,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -222,7 +262,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -239,7 +278,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -256,7 +294,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -273,7 +310,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -290,7 +326,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -307,7 +342,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -324,7 +358,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -341,7 +374,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -358,7 +390,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -375,7 +406,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -392,7 +422,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -409,7 +438,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -426,7 +454,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -443,7 +470,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -453,6 +479,18 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
||||
"integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||
@@ -460,7 +498,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -474,7 +511,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -488,7 +524,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -502,7 +537,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -516,7 +550,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -530,7 +563,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -544,7 +576,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -558,7 +589,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -572,7 +602,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -586,7 +615,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -600,7 +628,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -614,7 +641,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -628,7 +654,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -642,7 +667,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -656,7 +680,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -670,7 +693,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -684,7 +706,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -698,7 +719,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -712,7 +732,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -726,7 +745,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -740,7 +758,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -754,7 +771,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -768,7 +784,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -782,7 +797,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -796,7 +810,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -807,14 +820,156 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz",
|
||||
"integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rolldown/pluginutils": "1.0.0-rc.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0",
|
||||
"vue": "^3.2.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz",
|
||||
"integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/shared": "3.5.27",
|
||||
"entities": "^7.0.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz",
|
||||
"integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz",
|
||||
"integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/compiler-core": "3.5.27",
|
||||
"@vue/compiler-dom": "3.5.27",
|
||||
"@vue/compiler-ssr": "3.5.27",
|
||||
"@vue/shared": "3.5.27",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.6",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz",
|
||||
"integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz",
|
||||
"integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz",
|
||||
"integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz",
|
||||
"integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.27",
|
||||
"@vue/runtime-core": "3.5.27",
|
||||
"@vue/shared": "3.5.27",
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz",
|
||||
"integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz",
|
||||
"integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -852,11 +1007,16 @@
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -874,7 +1034,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -885,11 +1044,19 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -908,16 +1075,13 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -929,7 +1093,6 @@
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -958,7 +1121,6 @@
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
@@ -1003,7 +1165,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -1013,7 +1174,6 @@
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
@@ -1026,11 +1186,16 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
@@ -1100,6 +1265,46 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
|
||||
"integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.27",
|
||||
"@vue/compiler-sfc": "3.5.27",
|
||||
"@vue/runtime-dom": "3.5.27",
|
||||
"@vue/server-renderer": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-echarts": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-8.0.1.tgz",
|
||||
"integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"echarts": "^6.0.0",
|
||||
"vue": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,16 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"build": "vite build && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html",
|
||||
"test": "node --test tests/*.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"echarts": "^6.0.0",
|
||||
"vue": "^3.5.27",
|
||||
"vue-echarts": "^8.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
170
frontend/src/qc-gate/App.vue
Normal file
170
frontend/src/qc-gate/App.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import QcGateChart from './components/QcGateChart.vue';
|
||||
import LotTable from './components/LotTable.vue';
|
||||
import { useQcGateData } from './composables/useQcGateData.js';
|
||||
|
||||
const {
|
||||
stations,
|
||||
cacheTime,
|
||||
loading,
|
||||
refreshing,
|
||||
errorMessage,
|
||||
allLots,
|
||||
refreshNow,
|
||||
} = useQcGateData();
|
||||
|
||||
const activeFilter = ref(null);
|
||||
|
||||
const BUCKET_LABELS = {
|
||||
lt_6h: '<6hr',
|
||||
'6h_12h': '6-12hr',
|
||||
'12h_24h': '12-24hr',
|
||||
gt_24h: '>24hr',
|
||||
};
|
||||
|
||||
const hasStations = computed(() => stations.value.length > 0);
|
||||
|
||||
const totalLots = computed(() => {
|
||||
return stations.value.reduce((sum, station) => sum + Number(station.total || 0), 0);
|
||||
});
|
||||
|
||||
const filteredLots = computed(() => {
|
||||
if (!activeFilter.value) {
|
||||
return allLots.value;
|
||||
}
|
||||
|
||||
return allLots.value.filter((lot) => {
|
||||
return (
|
||||
lot.step === activeFilter.value.station &&
|
||||
lot.bucket === activeFilter.value.bucket
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const formattedCacheTime = computed(() => {
|
||||
if (!cacheTime.value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const date = new Date(cacheTime.value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(cacheTime.value);
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-TW', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
});
|
||||
|
||||
const activeFilterLabel = computed(() => {
|
||||
if (!activeFilter.value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const bucketLabel = BUCKET_LABELS[activeFilter.value.bucket] || activeFilter.value.bucket;
|
||||
return `${activeFilter.value.station} / ${bucketLabel}`;
|
||||
});
|
||||
|
||||
function handleChartSelect(filter) {
|
||||
if (!filter?.station || !filter?.bucket) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
activeFilter.value &&
|
||||
activeFilter.value.station === filter.station &&
|
||||
activeFilter.value.bucket === filter.bucket
|
||||
) {
|
||||
activeFilter.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
activeFilter.value = filter;
|
||||
}
|
||||
|
||||
function clearFilter() {
|
||||
activeFilter.value = null;
|
||||
}
|
||||
|
||||
function handleManualRefresh() {
|
||||
void refreshNow();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="qc-gate-page">
|
||||
<header class="qc-gate-header">
|
||||
<div>
|
||||
<h1>QC-GATE 狀態</h1>
|
||||
<p class="header-subtitle">即時監控各 QC-GATE 站點的在製 LOT 與等待時間</p>
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">快照時間</span>
|
||||
<span class="meta-value">{{ formattedCacheTime }}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">總 LOT</span>
|
||||
<span class="meta-value">{{ totalLots.toLocaleString('zh-TW') }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="refresh-button"
|
||||
:disabled="loading || refreshing"
|
||||
@click="handleManualRefresh"
|
||||
>
|
||||
{{ refreshing ? '更新中...' : '重新整理' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="errorMessage" class="error-banner">{{ errorMessage }}</div>
|
||||
|
||||
<main class="qc-gate-content">
|
||||
<section class="panel chart-panel">
|
||||
<div class="panel-header">
|
||||
<h2>站點等待時間分布</h2>
|
||||
<span class="panel-hint">點擊圖表區段可篩選下方 LOT 清單</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">資料載入中...</div>
|
||||
|
||||
<template v-else>
|
||||
<QcGateChart
|
||||
:stations="stations"
|
||||
:active-filter="activeFilter"
|
||||
@select-segment="handleChartSelect"
|
||||
/>
|
||||
<div v-if="!hasStations" class="empty-state">目前無 QC-GATE LOT</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="panel table-panel">
|
||||
<div class="panel-header">
|
||||
<h2>LOT 明細</h2>
|
||||
<button
|
||||
v-if="activeFilter"
|
||||
type="button"
|
||||
class="filter-indicator"
|
||||
@click="clearFilter"
|
||||
>
|
||||
篩選中:{{ activeFilterLabel }}(點擊清除)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LotTable
|
||||
:lots="filteredLots"
|
||||
:active-filter="activeFilter"
|
||||
@clear-filter="clearFilter"
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
176
frontend/src/qc-gate/components/LotTable.vue
Normal file
176
frontend/src/qc-gate/components/LotTable.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
lots: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
activeFilter: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['clear-filter']);
|
||||
|
||||
const sortKey = ref('wait_hours');
|
||||
const sortDirection = ref('desc');
|
||||
|
||||
const BUCKET_LABELS = {
|
||||
lt_6h: '<6hr',
|
||||
'6h_12h': '6-12hr',
|
||||
'12h_24h': '12-24hr',
|
||||
gt_24h: '>24hr',
|
||||
};
|
||||
|
||||
const HEADERS = [
|
||||
{ key: 'lot_id', label: 'LOT ID' },
|
||||
{ key: 'product', label: 'Product' },
|
||||
{ key: 'qty', label: 'QTY' },
|
||||
{ key: 'step', label: '站點' },
|
||||
{ key: 'workorder', label: 'Workorder' },
|
||||
{ key: 'move_in_time', label: 'Move In' },
|
||||
{ key: 'wait_hours', label: 'Wait (hr)' },
|
||||
{ key: 'bucket', label: '區間' },
|
||||
{ key: 'status', label: '狀態' },
|
||||
];
|
||||
|
||||
function normalizeForSort(value, key) {
|
||||
if (key === 'qty' || key === 'wait_hours') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
return String(value).toUpperCase();
|
||||
}
|
||||
|
||||
function toggleSort(key) {
|
||||
if (sortKey.value === key) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
|
||||
return;
|
||||
}
|
||||
sortKey.value = key;
|
||||
sortDirection.value = key === 'wait_hours' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
const sortedLots = computed(() => {
|
||||
const rows = Array.isArray(props.lots) ? [...props.lots] : [];
|
||||
const direction = sortDirection.value === 'asc' ? 1 : -1;
|
||||
const key = sortKey.value;
|
||||
|
||||
rows.sort((left, right) => {
|
||||
const leftValue = normalizeForSort(left?.[key], key);
|
||||
const rightValue = normalizeForSort(right?.[key], key);
|
||||
if (leftValue < rightValue) return -1 * direction;
|
||||
if (leftValue > rightValue) return 1 * direction;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return rows;
|
||||
});
|
||||
|
||||
function formatValue(value, fallback = '-') {
|
||||
if (value == null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatQty(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return '-';
|
||||
}
|
||||
return parsed.toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function formatWait(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return '-';
|
||||
}
|
||||
return parsed.toFixed(1);
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
return date.toLocaleString('zh-TW', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function bucketLabel(value) {
|
||||
return BUCKET_LABELS[value] || value || '-';
|
||||
}
|
||||
|
||||
function bucketClass(value) {
|
||||
return `bucket-${String(value || '').replace(/[^a-z0-9]+/gi, '-')}`;
|
||||
}
|
||||
|
||||
function currentSortLabel(columnKey) {
|
||||
if (sortKey.value !== columnKey) {
|
||||
return '';
|
||||
}
|
||||
return sortDirection.value === 'asc' ? '▲' : '▼';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lot-table-wrap">
|
||||
<div v-if="activeFilter" class="filter-chip" @click="emit('clear-filter')">
|
||||
已套用圖表篩選,點擊清除
|
||||
</div>
|
||||
|
||||
<div class="lot-table-scroll">
|
||||
<table class="lot-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="header in HEADERS" :key="header.key">
|
||||
<button
|
||||
type="button"
|
||||
class="sort-button"
|
||||
@click="toggleSort(header.key)"
|
||||
>
|
||||
{{ header.label }}
|
||||
<span class="sort-indicator">{{ currentSortLabel(header.key) }}</span>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="lot in sortedLots" :key="`${lot.lot_id}-${lot.step}-${lot.move_in_time}`">
|
||||
<td>{{ formatValue(lot.lot_id) }}</td>
|
||||
<td>{{ formatValue(lot.product) }}</td>
|
||||
<td class="cell-number">{{ formatQty(lot.qty) }}</td>
|
||||
<td>{{ formatValue(lot.step) }}</td>
|
||||
<td>{{ formatValue(lot.workorder) }}</td>
|
||||
<td>{{ formatTime(lot.move_in_time) }}</td>
|
||||
<td class="cell-number">{{ formatWait(lot.wait_hours) }}</td>
|
||||
<td>
|
||||
<span class="bucket-pill" :class="bucketClass(lot.bucket)">
|
||||
{{ bucketLabel(lot.bucket) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatValue(lot.status) }}</td>
|
||||
</tr>
|
||||
<tr v-if="sortedLots.length === 0">
|
||||
<td class="table-empty" :colspan="HEADERS.length">目前無符合條件的 LOT</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
150
frontend/src/qc-gate/components/QcGateChart.vue
Normal file
150
frontend/src/qc-gate/components/QcGateChart.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import VChart from 'vue-echarts';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { BarChart } from 'echarts/charts';
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
|
||||
|
||||
use([CanvasRenderer, BarChart, GridComponent, LegendComponent, TooltipComponent]);
|
||||
|
||||
const props = defineProps({
|
||||
stations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
activeFilter: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select-segment']);
|
||||
|
||||
const BUCKETS = [
|
||||
{ key: 'lt_6h', label: '<6hr', color: '#22c55e' },
|
||||
{ key: '6h_12h', label: '6-12hr', color: '#facc15' },
|
||||
{ key: '12h_24h', label: '12-24hr', color: '#fb923c' },
|
||||
{ key: 'gt_24h', label: '>24hr', color: '#ef4444' },
|
||||
];
|
||||
|
||||
function isSelected(stationName, bucketKey) {
|
||||
return (
|
||||
props.activeFilter &&
|
||||
props.activeFilter.station === stationName &&
|
||||
props.activeFilter.bucket === bucketKey
|
||||
);
|
||||
}
|
||||
|
||||
function hasActiveFilter() {
|
||||
return Boolean(props.activeFilter?.station && props.activeFilter?.bucket);
|
||||
}
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const stationNames = props.stations.map((station) => station.specname);
|
||||
|
||||
return {
|
||||
animationDuration: 350,
|
||||
animationDurationUpdate: 300,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
},
|
||||
legend: {
|
||||
top: 4,
|
||||
icon: 'roundRect',
|
||||
itemHeight: 10,
|
||||
textStyle: {
|
||||
color: '#334155',
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 24,
|
||||
right: 16,
|
||||
bottom: 18,
|
||||
top: 46,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: stationNames,
|
||||
axisLabel: {
|
||||
color: '#334155',
|
||||
fontSize: 11,
|
||||
interval: 0,
|
||||
rotate: stationNames.length > 8 ? 30 : 0,
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#cbd5e1',
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'LOT 數',
|
||||
nameTextStyle: {
|
||||
color: '#64748b',
|
||||
padding: [0, 16, 0, 0],
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#475569',
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#e2e8f0',
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
},
|
||||
series: BUCKETS.map((bucket) => ({
|
||||
id: bucket.key,
|
||||
name: bucket.label,
|
||||
type: 'bar',
|
||||
stack: 'lots',
|
||||
color: bucket.color,
|
||||
barMaxWidth: 40,
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
data: props.stations.map((station) => {
|
||||
const count = Number(station?.buckets?.[bucket.key] || 0);
|
||||
const opacity = hasActiveFilter() && !isSelected(station.specname, bucket.key) ? 0.25 : 1;
|
||||
return {
|
||||
value: count,
|
||||
itemStyle: { opacity },
|
||||
};
|
||||
}),
|
||||
itemStyle: {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
function handleChartClick(params) {
|
||||
if (!params?.name || !params?.seriesId) {
|
||||
return;
|
||||
}
|
||||
emit('select-segment', {
|
||||
station: String(params.name),
|
||||
bucket: String(params.seriesId),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="qc-gate-chart">
|
||||
<VChart
|
||||
v-if="stations.length"
|
||||
class="chart-canvas"
|
||||
:option="chartOption"
|
||||
autoresize
|
||||
@click="handleChartClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
167
frontend/src/qc-gate/composables/useQcGateData.js
Normal file
167
frontend/src/qc-gate/composables/useQcGateData.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import { apiGet } from '../../core/api.js';
|
||||
|
||||
const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
|
||||
const API_TIMEOUT_MS = 60000;
|
||||
const BUCKET_KEYS = ['lt_6h', '6h_12h', '12h_24h', 'gt_24h'];
|
||||
|
||||
function normalizeBuckets(rawBuckets) {
|
||||
const buckets = {};
|
||||
for (const key of BUCKET_KEYS) {
|
||||
const value = Number(rawBuckets?.[key]);
|
||||
buckets[key] = Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
function normalizeStation(rawStation) {
|
||||
const lots = Array.isArray(rawStation?.lots) ? rawStation.lots : [];
|
||||
return {
|
||||
specname: String(rawStation?.specname ?? '').trim(),
|
||||
spec_order: Number(rawStation?.spec_order ?? 999999),
|
||||
buckets: normalizeBuckets(rawStation?.buckets),
|
||||
total: Number(rawStation?.total ?? lots.length),
|
||||
lots,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePayload(payload) {
|
||||
const stations = Array.isArray(payload?.stations)
|
||||
? payload.stations.map(normalizeStation).filter((station) => station.specname)
|
||||
: [];
|
||||
|
||||
return {
|
||||
cache_time: payload?.cache_time ?? null,
|
||||
stations,
|
||||
};
|
||||
}
|
||||
|
||||
function toComparableWaitHours(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function useQcGateData() {
|
||||
const stations = ref([]);
|
||||
const cacheTime = ref(null);
|
||||
const loading = ref(true);
|
||||
const refreshing = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const lastFetchedAt = ref(null);
|
||||
|
||||
let refreshTimer = null;
|
||||
let currentRequest = null;
|
||||
|
||||
const allLots = computed(() => {
|
||||
const merged = [];
|
||||
for (const station of stations.value) {
|
||||
const stationLots = Array.isArray(station.lots) ? station.lots : [];
|
||||
merged.push(...stationLots);
|
||||
}
|
||||
return merged.sort(
|
||||
(left, right) => toComparableWaitHours(right.wait_hours) - toComparableWaitHours(left.wait_hours)
|
||||
);
|
||||
});
|
||||
|
||||
const fetchData = async ({ background = false } = {}) => {
|
||||
if (currentRequest) {
|
||||
currentRequest.abort();
|
||||
}
|
||||
|
||||
currentRequest = new AbortController();
|
||||
|
||||
if (background) {
|
||||
refreshing.value = true;
|
||||
} else {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const response = await apiGet('/api/qc-gate/summary', {
|
||||
timeout: API_TIMEOUT_MS,
|
||||
signal: currentRequest.signal,
|
||||
});
|
||||
|
||||
const payload = response?.success ? response.data : response;
|
||||
const normalized = normalizePayload(payload || {});
|
||||
|
||||
stations.value = normalized.stations;
|
||||
cacheTime.value = normalized.cache_time;
|
||||
lastFetchedAt.value = new Date();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
return false;
|
||||
}
|
||||
errorMessage.value = error?.message || '載入 QC-GATE 資料失敗';
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
refreshing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startAutoRefresh = () => {
|
||||
stopAutoRefresh();
|
||||
refreshTimer = setInterval(() => {
|
||||
if (!document.hidden) {
|
||||
void fetchData({ background: true });
|
||||
}
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const resetAutoRefresh = () => {
|
||||
startAutoRefresh();
|
||||
};
|
||||
|
||||
const refreshNow = async () => {
|
||||
resetAutoRefresh();
|
||||
await fetchData({ background: true });
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
return;
|
||||
}
|
||||
void fetchData({ background: true });
|
||||
resetAutoRefresh();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void fetchData({ background: false });
|
||||
startAutoRefresh();
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopAutoRefresh();
|
||||
if (currentRequest) {
|
||||
currentRequest.abort();
|
||||
currentRequest = null;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
});
|
||||
|
||||
return {
|
||||
stations,
|
||||
cacheTime,
|
||||
loading,
|
||||
refreshing,
|
||||
errorMessage,
|
||||
lastFetchedAt,
|
||||
allLots,
|
||||
fetchData,
|
||||
refreshNow,
|
||||
resetAutoRefresh,
|
||||
};
|
||||
}
|
||||
12
frontend/src/qc-gate/index.html
Normal file
12
frontend/src/qc-gate/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>QC-GATE 狀態</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
6
frontend/src/qc-gate/main.js
Normal file
6
frontend/src/qc-gate/main.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
318
frontend/src/qc-gate/style.css
Normal file
318
frontend/src/qc-gate/style.css
Normal file
@@ -0,0 +1,318 @@
|
||||
:root {
|
||||
--bg: #f5f7fa;
|
||||
--card-bg: #ffffff;
|
||||
--text: #1f2937;
|
||||
--muted: #64748b;
|
||||
--border: #dbe3ef;
|
||||
--header-from: #667eea;
|
||||
--header-to: #764ba2;
|
||||
--success: #22c55e;
|
||||
--warning: #facc15;
|
||||
--danger: #ef4444;
|
||||
--shadow: 0 2px 10px rgba(15, 23, 42, 0.08);
|
||||
--shadow-strong: 0 6px 24px rgba(102, 126, 234, 0.24);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Microsoft JhengHei', Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.qc-gate-page {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
max-width: 1900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.qc-gate-header {
|
||||
background: linear-gradient(135deg, var(--header-from) 0%, var(--header-to) 100%);
|
||||
color: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-strong);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.qc-gate-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refresh-button:hover {
|
||||
background: rgba(255, 255, 255, 0.26);
|
||||
}
|
||||
|
||||
.refresh-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.qc-gate-content {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.panel-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 280px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chart-panel {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.qc-gate-chart {
|
||||
min-height: 320px;
|
||||
padding: 10px 14px 2px;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
width: 100%;
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
.filter-indicator {
|
||||
border: 1px solid #93c5fd;
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-indicator:hover {
|
||||
background: #bfdbfe;
|
||||
}
|
||||
|
||||
.lot-table-wrap {
|
||||
padding: 12px 14px 16px;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #93c5fd;
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.lot-table-scroll {
|
||||
overflow: auto;
|
||||
max-height: 520px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.lot-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 1280px;
|
||||
}
|
||||
|
||||
.lot-table th,
|
||||
.lot-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lot-table th {
|
||||
background: #f1f5f9;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sort-button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sort-indicator {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
min-width: 10px;
|
||||
}
|
||||
|
||||
.lot-table tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.cell-number {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.table-empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.bucket-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.bucket-lt-6h {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.bucket-6h-12h {
|
||||
background: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
.bucket-12h-24h {
|
||||
background: #ffedd5;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.bucket-gt-24h {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.qc-gate-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.qc-gate-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'node:path';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: '/static/dist/',
|
||||
plugins: [vue()],
|
||||
publicDir: false,
|
||||
build: {
|
||||
outDir: '../src/mes_dashboard/static/dist',
|
||||
@@ -19,19 +22,31 @@ export default defineConfig(({ mode }) => ({
|
||||
'excel-query': resolve(__dirname, 'src/excel-query/main.js'),
|
||||
tables: resolve(__dirname, 'src/tables/main.js'),
|
||||
'query-tool': resolve(__dirname, 'src/query-tool/main.js'),
|
||||
'tmtt-defect': resolve(__dirname, 'src/tmtt-defect/main.js')
|
||||
'tmtt-defect': resolve(__dirname, 'src/tmtt-defect/main.js'),
|
||||
'qc-gate': resolve(__dirname, 'src/qc-gate/index.html')
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].js',
|
||||
chunkFileNames: 'chunks/[name]-[hash].js',
|
||||
assetFileNames: '[name][extname]',
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) {
|
||||
const normalizedId = id.replace(/\\/g, '/');
|
||||
if (!normalizedId.includes('node_modules')) {
|
||||
return;
|
||||
}
|
||||
if (id.includes('echarts')) {
|
||||
if (
|
||||
normalizedId.includes('/node_modules/echarts/') ||
|
||||
normalizedId.includes('/node_modules/zrender/') ||
|
||||
normalizedId.includes('/node_modules/vue-echarts/')
|
||||
) {
|
||||
return 'vendor-echarts';
|
||||
}
|
||||
if (
|
||||
normalizedId.includes('/node_modules/vue/') ||
|
||||
normalizedId.includes('/node_modules/@vue/')
|
||||
) {
|
||||
return 'vendor-vue';
|
||||
}
|
||||
return 'vendor';
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,614 +0,0 @@
|
||||
{
|
||||
"version": "2.6",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "GIoPU",
|
||||
"x": 950,
|
||||
"y": 0,
|
||||
"name": "WIP Overview - Integrated",
|
||||
"width": 1200,
|
||||
"fill": "#F5F7FA",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": 20,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "N2qxA",
|
||||
"name": "header",
|
||||
"width": "fill_container",
|
||||
"height": 64,
|
||||
"fill": {
|
||||
"type": "gradient",
|
||||
"gradientType": "linear",
|
||||
"enabled": true,
|
||||
"rotation": 135,
|
||||
"size": {
|
||||
"height": 1
|
||||
},
|
||||
"colors": [
|
||||
{
|
||||
"color": "#667eea",
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
"color": "#764ba2",
|
||||
"position": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"cornerRadius": 10,
|
||||
"padding": [
|
||||
0,
|
||||
22
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "7h8YC",
|
||||
"name": "headerTitle",
|
||||
"fill": "#FFFFFF",
|
||||
"content": "WIP Overview Dashboard",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "JyskI",
|
||||
"name": "headerRight",
|
||||
"gap": 16,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "8rtgc",
|
||||
"name": "lastUpdate",
|
||||
"fill": "rgba(255,255,255,0.8)",
|
||||
"content": "Last Update: 2026-01-27 14:30",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ZH0PW",
|
||||
"name": "refreshBtn",
|
||||
"fill": "rgba(255,255,255,0.2)",
|
||||
"cornerRadius": 8,
|
||||
"padding": [
|
||||
9,
|
||||
20
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "wlrMh",
|
||||
"name": "refreshText",
|
||||
"fill": "#FFFFFF",
|
||||
"content": "重新整理",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "aYXjP",
|
||||
"name": "Summary Section",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 12,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pFof4",
|
||||
"name": "kpiRow",
|
||||
"width": "fill_container",
|
||||
"gap": 14,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "0kWPh",
|
||||
"name": "kpi1",
|
||||
"width": "fill_container",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": 10,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#E2E6EF"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
16,
|
||||
20
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "DTtUq",
|
||||
"name": "kpi1Label",
|
||||
"fill": "#666666",
|
||||
"content": "Total Lots",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "vdmq8",
|
||||
"name": "kpi1Value",
|
||||
"fill": "#667eea",
|
||||
"content": "1,234",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 28,
|
||||
"fontWeight": "700"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "wEupl",
|
||||
"name": "kpi2",
|
||||
"width": "fill_container",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": 10,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#E2E6EF"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
16,
|
||||
20
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "59OHd",
|
||||
"name": "kpi2Label",
|
||||
"fill": "#666666",
|
||||
"content": "Total QTY",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "YkPVl",
|
||||
"name": "kpi2Value",
|
||||
"fill": "#667eea",
|
||||
"content": "56,789",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 28,
|
||||
"fontWeight": "700"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "g65nT",
|
||||
"name": "wipStatusRow",
|
||||
"width": "fill_container",
|
||||
"gap": 14,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "sbKdU",
|
||||
"name": "runCard",
|
||||
"width": "fill_container",
|
||||
"fill": "#F0FDF4",
|
||||
"cornerRadius": 10,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 2,
|
||||
"fill": "#22C55E"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [
|
||||
16,
|
||||
20
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "EQzBo",
|
||||
"name": "runLeft",
|
||||
"width": "fill_container",
|
||||
"gap": 10,
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"cornerRadius": 5,
|
||||
"id": "m7Prk",
|
||||
"name": "runDot",
|
||||
"fill": "#22C55E",
|
||||
"width": 10,
|
||||
"height": 10
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "1DMEu",
|
||||
"name": "runLabel",
|
||||
"fill": "#166534",
|
||||
"content": "RUN",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ZVtRH",
|
||||
"name": "runRight",
|
||||
"width": "fill_container",
|
||||
"gap": 24,
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "OLwma",
|
||||
"name": "runLots",
|
||||
"fill": "#0D0D0D",
|
||||
"content": "500 lots",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "700"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "OI5f5",
|
||||
"name": "runQty",
|
||||
"fill": "#166534",
|
||||
"content": "30,000 pcs",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "700"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "uibRH",
|
||||
"name": "queueCard",
|
||||
"width": "fill_container",
|
||||
"fill": "#FFFBEB",
|
||||
"cornerRadius": 10,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 2,
|
||||
"fill": "#F59E0B"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [
|
||||
16,
|
||||
20
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "xeGDP",
|
||||
"name": "queueLeft",
|
||||
"width": "fill_container",
|
||||
"gap": 10,
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"cornerRadius": 5,
|
||||
"id": "KuAgl",
|
||||
"name": "queueDot",
|
||||
"fill": "#F59E0B",
|
||||
"width": 10,
|
||||
"height": 10
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "TsD9B",
|
||||
"name": "queueLabel",
|
||||
"fill": "#92400E",
|
||||
"content": "QUEUE",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "41Db3",
|
||||
"name": "queueRight",
|
||||
"width": "fill_container",
|
||||
"gap": 24,
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "dtaqd",
|
||||
"name": "queueLots",
|
||||
"fill": "#0D0D0D",
|
||||
"content": "634 lots",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "700"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "BVusD",
|
||||
"name": "queueQty",
|
||||
"fill": "#92400E",
|
||||
"content": "21,789 pcs",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "700"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "Y5gLu",
|
||||
"name": "holdCard",
|
||||
"width": "fill_container",
|
||||
"fill": "#FEF2F2",
|
||||
"cornerRadius": 10,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 2,
|
||||
"fill": "#EF4444"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [
|
||||
16,
|
||||
20
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "juHZC",
|
||||
"name": "holdLeft",
|
||||
"width": "fill_container",
|
||||
"gap": 10,
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"cornerRadius": 5,
|
||||
"id": "FW9Vv",
|
||||
"name": "holdDot",
|
||||
"fill": "#EF4444",
|
||||
"width": 10,
|
||||
"height": 10
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "gEojA",
|
||||
"name": "holdLabel",
|
||||
"fill": "#991B1B",
|
||||
"content": "HOLD",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "3imiS",
|
||||
"name": "holdRight",
|
||||
"width": "fill_container",
|
||||
"gap": 24,
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "AlTi3",
|
||||
"name": "holdLots",
|
||||
"fill": "#0D0D0D",
|
||||
"content": "100 lots",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "700"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "oKc0i",
|
||||
"name": "holdQty",
|
||||
"fill": "#991B1B",
|
||||
"content": "5,000 pcs",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "700"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "uRXyA",
|
||||
"name": "Content Grid",
|
||||
"width": "fill_container",
|
||||
"gap": 16,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "7HMip",
|
||||
"name": "matrixCard",
|
||||
"width": "fill_container",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": 10,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#E2E6EF"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pxsYm",
|
||||
"name": "matrixHeader",
|
||||
"width": "fill_container",
|
||||
"fill": "#FAFBFC",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "#E2E6EF"
|
||||
},
|
||||
"padding": [
|
||||
14,
|
||||
20
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "JhSDl",
|
||||
"name": "matrixTitle",
|
||||
"fill": "#222222",
|
||||
"content": "Workcenter x Package Matrix (QTY)",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 15,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "4hQZP",
|
||||
"name": "matrixBody",
|
||||
"width": "fill_container",
|
||||
"height": 200,
|
||||
"layout": "vertical",
|
||||
"padding": 16,
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "lH6Yr",
|
||||
"name": "matrixPlaceholder",
|
||||
"fill": "#999999",
|
||||
"content": "[ Matrix Table ]",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "FOIFS",
|
||||
"name": "holdSummaryCard",
|
||||
"width": 320,
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": 10,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#E2E6EF"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "uikVi",
|
||||
"name": "holdSummaryHeader",
|
||||
"width": "fill_container",
|
||||
"fill": "#FAFBFC",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "#E2E6EF"
|
||||
},
|
||||
"padding": [
|
||||
14,
|
||||
20
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "VBWBv",
|
||||
"name": "holdSummaryTitle",
|
||||
"fill": "#222222",
|
||||
"content": "Hold Summary",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 15,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cFEPm",
|
||||
"name": "holdSummaryBody",
|
||||
"width": "fill_container",
|
||||
"height": 200,
|
||||
"layout": "vertical",
|
||||
"padding": 16,
|
||||
"justifyContent": "center",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "s7sa1",
|
||||
"name": "holdSummaryPlaceholder",
|
||||
"fill": "#999999",
|
||||
"content": "[ Hold Table ]",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-09
|
||||
180
openspec/changes/archive/2026-02-09-qc-gate-report/design.md
Normal file
180
openspec/changes/archive/2026-02-09-qc-gate-report/design.md
Normal file
@@ -0,0 +1,180 @@
|
||||
## Context
|
||||
|
||||
目前系統有 11 個頁面,全部使用 Jinja2 shell + Vite JS 混合模式。前端無 UI 框架(純 vanilla JS),ECharts 以靜態 minified 檔案引入(非 npm)。WIP 資料透過 Redis 快取,每 10 分鐘從 Oracle DWH.DW_MES_LOT_V 同步。
|
||||
|
||||
QC-GATE 頁面將是第一個採用 Vue 3 + Vite 純前端架構的頁面,建立後續遷移模式。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 提供 QC-GATE 站點即時 LOT 狀態報表(條圖 + 篩選清單)
|
||||
- 建立純 Vue 3 + Vite 頁面架構模式(不依賴 Jinja2)
|
||||
- 與現有 portal iframe 機制無縫整合
|
||||
- 複用現有 WIP Redis 快取,不增加 Oracle 查詢負擔
|
||||
|
||||
**Non-Goals:**
|
||||
- 不遷移現有頁面到 Vue 3(本次僅建立模式)
|
||||
- 不引入 Vue Router 或 Pinia(單頁報表不需要)
|
||||
- 不修改現有 CSP 或安全策略
|
||||
- 不改動 WIP 快取更新機制
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: UI 框架選型 — Vue 3
|
||||
|
||||
**選擇**: Vue 3 (Composition API + SFC)
|
||||
**替代方案**: React (JSX 轉換成本較高), Svelte (生態系較小)
|
||||
**理由**: Vite 原生支援、template 語法接近 Jinja 降低遷移門檻、vue-echarts 整合成熟、支持漸進式頁面遷移
|
||||
|
||||
### D2: 頁面服務方式 — Vite MPA HTML entry + Flask 靜態服務
|
||||
|
||||
**選擇**: Vite build 產出完整 HTML (`frontend/src/qc-gate/index.html`),Flask 以 `send_from_directory` 服務
|
||||
**替代方案**: 最小 Jinja shell(仍有 Jinja 依賴)
|
||||
**理由**:
|
||||
- 完全脫離 Jinja2,建立乾淨的遷移模式
|
||||
- 此頁面為唯讀報表,只有 GET 請求,不需要 CSRF token 注入
|
||||
- Toast 功能由 Vue component 自行實作(不依賴 `_base.html` 的全域 toast)
|
||||
- Vite config 已有 `manualChunks` 設定,只需新增 HTML entry
|
||||
|
||||
### D3: ECharts 引入方式 — npm + tree-shaking
|
||||
|
||||
**選擇**: `npm install echarts vue-echarts`,使用 tree-shaking 只引入 bar chart 相關模組
|
||||
**替代方案**: 繼續使用靜態 minified 檔案(無法 tree-shake,~1MB)
|
||||
**理由**: 既有 Vite config 已有 `vendor-echarts` chunk split 邏輯;npm 引入後可 tree-shake 到只需 bar chart 模組(~200KB gzipped)
|
||||
|
||||
### D4: API 設計 — 新增 `/api/qc-gate/summary` 端點
|
||||
|
||||
**選擇**: 新建 `qc_gate_routes.py` blueprint + `qc_gate_service.py` 服務
|
||||
**替代方案**: 擴展現有 wip_routes(不符合 SRP)
|
||||
**理由**:
|
||||
- 從 WIP Redis 快取中讀取,篩選 `SPECNAME LIKE '%QC%GATE%'`
|
||||
- 使用 `DW_MES_SPEC_WORKCENTER_V`(已在 filter_cache.py 中快取)取得站點排序
|
||||
- 在後端完成 6HR 分級計算,前端只負責渲染
|
||||
- 回傳結構包含 summary(條圖資料)和 lots(清單資料)
|
||||
|
||||
### D5: QC-GATE 站點識別與排序
|
||||
|
||||
**選擇**: 從 WIP 快取篩選 `SPECNAME` 包含 "QC" 和 "GATE" 的 LOT,站點排序從 `DW_MES_SPEC_WORKCENTER_V` 的 `SPEC` 欄位匹配取得 `SPEC_ORDER`
|
||||
**理由**: SPECNAME 是 LOT 層級的製程步驟名稱,DW_MES_SPEC_WORKCENTER_V 是維度主表提供排序資訊
|
||||
|
||||
### D6: 等待時間分級
|
||||
|
||||
**選擇**: 四級分組
|
||||
- `< 6hr` — 正常(綠色)
|
||||
- `6-12hr` — 注意(黃色)
|
||||
- `12-24hr` — 警告(橙色)
|
||||
- `> 24hr` — 超時(紅色)
|
||||
|
||||
**計算**: `wait_hours = (SYS_DATE - MOVEINTIMESTAMP)` 以小時為單位,在後端計算
|
||||
|
||||
### D7: 自動刷新 — 複用 wip-overview 模式
|
||||
|
||||
**選擇**: 10 分鐘 `setInterval` + `visibilitychange` 即時刷新
|
||||
**理由**: 與 WIP 快取同步週期一致,避免無效請求;tab 隱藏時跳過刷新
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─ Vite Build ──────────────────────────────┐
|
||||
│ frontend/src/qc-gate/ │
|
||||
│ index.html ← HTML entry (no Jinja)│
|
||||
│ main.js ← createApp, mount │
|
||||
│ App.vue ← root layout │
|
||||
│ components/ │
|
||||
│ QcGateChart.vue ← ECharts stacked bar│
|
||||
│ LotTable.vue ← filterable table │
|
||||
│ composables/ │
|
||||
│ useQcGateData.js ← fetch + transform │
|
||||
│ useAutoRefresh.js← 10min refresh logic│
|
||||
│ style.css ← page styles │
|
||||
│ │
|
||||
│ Build output → static/dist/qc-gate.html │
|
||||
│ static/dist/qc-gate.js │
|
||||
│ static/dist/qc-gate.css │
|
||||
└───────────────────────────────────────────┘
|
||||
|
||||
┌─ Flask Backend ───────────────────────────┐
|
||||
│ routes/qc_gate_routes.py │
|
||||
│ GET /api/qc-gate/summary │
|
||||
│ │
|
||||
│ services/qc_gate_service.py │
|
||||
│ get_qc_gate_summary() │
|
||||
│ ├─ get_cached_wip_data() (Redis) │
|
||||
│ ├─ filter SPECNAME %QC%GATE% │
|
||||
│ ├─ compute wait_hours per LOT │
|
||||
│ └─ group by SPECNAME + bucket │
|
||||
│ │
|
||||
│ app.py │
|
||||
│ GET /qc-gate → send_from_directory() │
|
||||
└───────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## API Response Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"cache_time": "2026-02-09T14:30:00",
|
||||
"stations": [
|
||||
{
|
||||
"specname": "QC-GATE-DB",
|
||||
"spec_order": "030",
|
||||
"buckets": {
|
||||
"lt_6h": 12,
|
||||
"6h_12h": 5,
|
||||
"12h_24h": 3,
|
||||
"gt_24h": 1
|
||||
},
|
||||
"total": 21,
|
||||
"lots": [
|
||||
{
|
||||
"lot_id": "L001",
|
||||
"container_id": "C001",
|
||||
"product": "PKG-A",
|
||||
"qty": 5000,
|
||||
"step": "QC-GATE-DB",
|
||||
"workorder": "WO123",
|
||||
"move_in_time": "2026-02-09T08:30:00",
|
||||
"wait_hours": 6.0,
|
||||
"bucket": "6h_12h",
|
||||
"status": "QUEUE",
|
||||
"equipment": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Vite Config Changes
|
||||
|
||||
```js
|
||||
// vite.config.js additions
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
// ... existing entries ...
|
||||
'qc-gate': resolve(__dirname, 'src/qc-gate/index.html') // HTML entry
|
||||
},
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return;
|
||||
if (id.includes('echarts')) return 'vendor-echarts';
|
||||
if (id.includes('vue')) return 'vendor-vue'; // 新增 Vue chunk
|
||||
return 'vendor';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Vue plugin 影響既有 build]** → `@vitejs/plugin-vue` 只處理 `.vue` 檔案,不影響現有 `.js` entry points。已驗證 Vite plugin 系統為 additive。
|
||||
- **[ECharts npm vs 靜態檔案共存]** → 新頁面用 npm echarts,舊頁面繼續用靜態檔案。`vendor-echarts` chunk 只被 qc-gate 引用,不影響舊頁面 bundle size。
|
||||
- **[SPECNAME pattern 可能變動]** → 篩選邏輯集中在 `qc_gate_service.py` 單一位置,易於調整。
|
||||
- **[純靜態 HTML 無法使用 Flask template context]** → 此頁面為唯讀報表,不需要 CSRF、不需要 session 資料。認證由 portal iframe 外層處理。
|
||||
@@ -0,0 +1,32 @@
|
||||
## Why
|
||||
|
||||
目前系統缺乏 QC-GATE 站點的即時 LOT 狀態監控。QC-GATE 是製程中的品質關卡,LOT 在此站點的等待時間直接影響生產效率。需要一個視覺化報表即時呈現各 QC-GATE 站點的 LOT 分佈與等待時間,讓管理者快速識別瓶頸。
|
||||
|
||||
此頁面同時作為前端架構遷移的起點 — 第一個完全脫離 Jinja2 的純 Vue 3 + Vite 頁面,為後續頁面遷移建立模式。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 QC-GATE 即時狀態報表頁面(`/qc-gate`),使用 Vue 3 + ECharts 實作
|
||||
- 新增後端 API endpoint(`/api/qc-gate/summary`),從 WIP Redis cache 篩選 QC-GATE 相關 LOT
|
||||
- 前端引入 Vue 3 和 ECharts npm 套件,建立純 Vite 頁面架構模式
|
||||
- 頁面以 Vite HTML entry 方式建置,完全不使用 Jinja2 template
|
||||
- 頁面註冊至「報表類」drawer,狀態為 released
|
||||
- 使用 `DW_MES_SPEC_WORKCENTER_V` 取得 QC-GATE 站點清單與排序
|
||||
- 等待時間以 6 小時為基準分為四級:<6hr, 6-12hr, 12-24hr, >24hr
|
||||
- 支援 10 分鐘自動刷新與 visibilitychange 即時刷新
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `qc-gate-status-report`: QC-GATE 站點即時 LOT 狀態報表 — 包含 API 端點、Vue 3 前端頁面、圖表互動、清單篩選
|
||||
- `vue-vite-page-architecture`: 純 Vue 3 + Vite 頁面架構模式 — 脫離 Jinja2 的前端建置模式、CSRF/auth 處理、與 portal iframe 整合
|
||||
|
||||
### Modified Capabilities
|
||||
- `page-drawer-assignment`: 新增 qc-gate 頁面至報表類 drawer 的 page_status.json 配置
|
||||
|
||||
## Impact
|
||||
|
||||
- **前端**: 引入 `vue`、`echarts`、`vue-echarts` npm 依賴;修改 `vite.config.js` 加入 Vue plugin 和新 entry point
|
||||
- **後端**: 新增 `qc_gate_routes.py` blueprint 和 `qc_gate_service.py` 服務;新增 Flask route serving 純靜態 HTML
|
||||
- **配置**: `page_status.json` 新增 qc-gate 頁面定義
|
||||
- **建置**: Vite config 需加入 `@vitejs/plugin-vue` 和 HTML entry
|
||||
@@ -0,0 +1,80 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: System SHALL provide QC-GATE LOT status API
|
||||
The system SHALL provide an API endpoint that returns real-time LOT status for all QC-GATE stations, with wait time classification.
|
||||
|
||||
#### Scenario: Retrieve QC-GATE summary
|
||||
- **WHEN** user sends GET `/api/qc-gate/summary`
|
||||
- **THEN** the system SHALL return all LOTs whose `SPECNAME` contains both "QC" and "GATE" (case-insensitive)
|
||||
- **THEN** each LOT SHALL include `wait_hours` calculated as `(SYS_DATE - MOVEINTIMESTAMP)` in hours
|
||||
- **THEN** each LOT SHALL be classified into a time bucket: `lt_6h`, `6h_12h`, `12h_24h`, or `gt_24h`
|
||||
- **THEN** the response SHALL include per-station bucket counts and the full lot list
|
||||
|
||||
#### Scenario: QC-GATE data sourced from WIP cache
|
||||
- **WHEN** the API is called
|
||||
- **THEN** the system SHALL read from the existing WIP Redis cache (not direct Oracle query)
|
||||
- **THEN** the response SHALL include `cache_time` indicating the WIP snapshot timestamp
|
||||
|
||||
#### Scenario: No QC-GATE lots in cache
|
||||
- **WHEN** no LOTs match the QC-GATE SPECNAME pattern
|
||||
- **THEN** the system SHALL return an empty `stations` array with `cache_time`
|
||||
|
||||
### Requirement: QC-GATE stations SHALL be ordered by spec sequence
|
||||
The system SHALL order QC-GATE stations according to the manufacturing flow sequence from `DW_MES_SPEC_WORKCENTER_V`.
|
||||
|
||||
#### Scenario: Station ordering
|
||||
- **WHEN** the API returns multiple QC-GATE stations
|
||||
- **THEN** the stations SHALL be sorted by `SPEC_ORDER` from `DW_MES_SPEC_WORKCENTER_V` where `SPEC` matches the SPECNAME
|
||||
|
||||
#### Scenario: Station not found in spec dimension table
|
||||
- **WHEN** a QC-GATE SPECNAME is not found in `DW_MES_SPEC_WORKCENTER_V`
|
||||
- **THEN** the station SHALL appear at the end of the list with a high default sort order
|
||||
|
||||
### Requirement: QC-GATE report page SHALL display stacked bar chart
|
||||
The page SHALL display a stacked bar chart showing LOT counts per QC-GATE station, grouped by wait time bucket.
|
||||
|
||||
#### Scenario: Bar chart rendering
|
||||
- **WHEN** the page loads and data is available
|
||||
- **THEN** the X-axis SHALL show QC-GATE station names
|
||||
- **THEN** the Y-axis SHALL show LOT counts
|
||||
- **THEN** each bar SHALL be stacked with four color-coded segments: <6hr (green), 6-12hr (yellow), 12-24hr (orange), >24hr (red)
|
||||
|
||||
#### Scenario: Empty state
|
||||
- **WHEN** no QC-GATE LOTs exist
|
||||
- **THEN** the chart area SHALL display a "目前無 QC-GATE LOT" message
|
||||
|
||||
### Requirement: QC-GATE report page SHALL display filterable LOT table
|
||||
The page SHALL display a table listing individual LOTs, with click-to-filter interaction from the bar chart.
|
||||
|
||||
#### Scenario: Default table display
|
||||
- **WHEN** the page loads
|
||||
- **THEN** the table SHALL show all QC-GATE LOTs sorted by wait time descending
|
||||
|
||||
#### Scenario: Click bar chart to filter
|
||||
- **WHEN** user clicks a specific segment of a bar (e.g., QC-GATE-DB's 6-12hr segment)
|
||||
- **THEN** the table SHALL filter to show only LOTs matching that station AND time bucket
|
||||
- **THEN** a filter indicator SHALL be visible showing the active filter
|
||||
|
||||
#### Scenario: Clear filter
|
||||
- **WHEN** user clicks the active filter indicator or clicks the same bar segment again
|
||||
- **THEN** the table SHALL return to showing all QC-GATE LOTs
|
||||
|
||||
### Requirement: QC-GATE report page SHALL auto-refresh
|
||||
The page SHALL automatically refresh data at the same interval as the WIP cache update cycle.
|
||||
|
||||
#### Scenario: Auto-refresh while visible
|
||||
- **WHEN** the page is visible and 10 minutes have elapsed since last refresh
|
||||
- **THEN** the page SHALL fetch new data from the API without showing a full loading overlay
|
||||
- **THEN** the chart and table SHALL update with new data
|
||||
|
||||
#### Scenario: Auto-refresh while hidden
|
||||
- **WHEN** the page tab/iframe is hidden (document.hidden === true)
|
||||
- **THEN** the auto-refresh SHALL be skipped
|
||||
|
||||
#### Scenario: Page becomes visible after being hidden
|
||||
- **WHEN** the page becomes visible after being hidden
|
||||
- **THEN** the page SHALL immediately refresh data
|
||||
|
||||
#### Scenario: Manual refresh
|
||||
- **WHEN** user clicks the refresh button
|
||||
- **THEN** the page SHALL fetch new data and reset the auto-refresh timer
|
||||
@@ -0,0 +1,41 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Pure Vite pages SHALL be served as static HTML
|
||||
The system SHALL support serving Vite-built HTML pages directly via Flask without Jinja2 rendering.
|
||||
|
||||
#### Scenario: Serve pure Vite page
|
||||
- **WHEN** user navigates to a pure Vite page route (e.g., `/qc-gate`)
|
||||
- **THEN** Flask SHALL serve the pre-built HTML file from `static/dist/` via `send_from_directory`
|
||||
- **THEN** the HTML SHALL NOT pass through Jinja2 template rendering
|
||||
|
||||
#### Scenario: Page works in portal iframe
|
||||
- **WHEN** the pure Vite page is loaded inside the portal iframe
|
||||
- **THEN** the page SHALL render correctly within the iframe context
|
||||
- **THEN** CSP `frame-ancestors 'self'` SHALL allow the embedding
|
||||
|
||||
### Requirement: Vite config SHALL support Vue SFC and HTML entry points
|
||||
The Vite build configuration SHALL support Vue Single File Components alongside existing vanilla JS entries.
|
||||
|
||||
#### Scenario: Vue plugin coexistence
|
||||
- **WHEN** `vite build` is executed
|
||||
- **THEN** Vue SFC (`.vue` files) SHALL be compiled by `@vitejs/plugin-vue`
|
||||
- **THEN** existing vanilla JS entry points SHALL continue to build without modification
|
||||
|
||||
#### Scenario: HTML entry point
|
||||
- **WHEN** a page uses an HTML file as its Vite entry point
|
||||
- **THEN** Vite SHALL process the HTML and its referenced JS/CSS into `static/dist/`
|
||||
- **THEN** the output SHALL include `<page-name>.html`, `<page-name>.js`, and `<page-name>.css`
|
||||
|
||||
#### Scenario: Chunk splitting
|
||||
- **WHEN** Vite builds the project
|
||||
- **THEN** Vue runtime SHALL be split into a `vendor-vue` chunk
|
||||
- **THEN** ECharts modules SHALL be split into the existing `vendor-echarts` chunk
|
||||
- **THEN** chunk splitting SHALL NOT affect existing page bundles
|
||||
|
||||
### Requirement: Pure Vite pages SHALL handle API calls without legacy MesApi
|
||||
Pure Vite pages SHALL use the existing `frontend/src/core/api.js` module for API communication without depending on the global `window.MesApi` object from `_base.html`.
|
||||
|
||||
#### Scenario: API GET request from pure Vite page
|
||||
- **WHEN** a pure Vite page makes a GET API call
|
||||
- **THEN** the call SHALL use the `apiGet` function from `core/api.js`
|
||||
- **THEN** the call SHALL work without `window.MesApi` being present
|
||||
31
openspec/changes/archive/2026-02-09-qc-gate-report/tasks.md
Normal file
31
openspec/changes/archive/2026-02-09-qc-gate-report/tasks.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## 1. Frontend Toolchain Setup
|
||||
|
||||
- [x] 1.1 Install npm dependencies: `vue`, `@vitejs/plugin-vue`, `echarts`, `vue-echarts`
|
||||
- [x] 1.2 Update `vite.config.js`: add Vue plugin, add `qc-gate` HTML entry point, add `vendor-vue` manual chunk
|
||||
|
||||
## 2. Backend API
|
||||
|
||||
- [x] 2.1 Create `services/qc_gate_service.py`: read WIP cache, filter SPECNAME by QC/GATE pattern, compute wait_hours and bucket classification, sort stations by SPEC_ORDER from filter_cache
|
||||
- [x] 2.2 Create `routes/qc_gate_routes.py`: blueprint with `GET /api/qc-gate/summary` endpoint
|
||||
- [x] 2.3 Register blueprint in `routes/__init__.py` and add Flask route `GET /qc-gate` serving static HTML via `send_from_directory`
|
||||
|
||||
## 3. Vue Frontend Page
|
||||
|
||||
- [x] 3.1 Create `frontend/src/qc-gate/index.html`: standalone HTML entry with Vue app mount point
|
||||
- [x] 3.2 Create `frontend/src/qc-gate/main.js`: Vue app bootstrap with createApp and mount
|
||||
- [x] 3.3 Create `frontend/src/qc-gate/App.vue`: root layout with header (title, cache time, refresh button), chart area, and table area
|
||||
- [x] 3.4 Create `frontend/src/qc-gate/composables/useQcGateData.js`: data fetching, 10min auto-refresh with visibilitychange, reactive state management
|
||||
- [x] 3.5 Create `frontend/src/qc-gate/components/QcGateChart.vue`: ECharts stacked bar chart (x=station, y=count, stacked by 4 time buckets with color coding)
|
||||
- [x] 3.6 Create `frontend/src/qc-gate/components/LotTable.vue`: sortable lot table with click-to-filter from chart, filter indicator, clear filter
|
||||
- [x] 3.7 Create `frontend/src/qc-gate/style.css`: page styling consistent with existing dashboard aesthetic
|
||||
|
||||
## 4. Page Registration & Integration
|
||||
|
||||
- [x] 4.1 Register qc-gate page in `page_status.json`: route `/qc-gate`, name `QC-GATE 狀態`, drawer_id `reports`, status `released`
|
||||
- [x] 4.2 Build frontend (`npm run build`) and verify output files exist in `static/dist/`
|
||||
|
||||
## 5. Verification
|
||||
|
||||
- [x] 5.1 Verify API endpoint returns correct data structure with QC-GATE filtered lots, wait time buckets, and station ordering
|
||||
- [x] 5.2 Verify page renders in portal iframe: chart displays, table populates, click-to-filter works, auto-refresh fires
|
||||
- [x] 5.3 Verify existing pages still build and function correctly (Vue plugin does not break vanilla JS entries)
|
||||
84
openspec/specs/qc-gate-status-report/spec.md
Normal file
84
openspec/specs/qc-gate-status-report/spec.md
Normal file
@@ -0,0 +1,84 @@
|
||||
## Purpose
|
||||
Define stable requirements for qc-gate-status-report.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
### Requirement: System SHALL provide QC-GATE LOT status API
|
||||
The system SHALL provide an API endpoint that returns real-time LOT status for all QC-GATE stations, with wait time classification.
|
||||
|
||||
#### Scenario: Retrieve QC-GATE summary
|
||||
- **WHEN** user sends GET `/api/qc-gate/summary`
|
||||
- **THEN** the system SHALL return all LOTs whose `SPECNAME` contains both "QC" and "GATE" (case-insensitive)
|
||||
- **THEN** each LOT SHALL include `wait_hours` calculated as `(SYS_DATE - MOVEINTIMESTAMP)` in hours
|
||||
- **THEN** each LOT SHALL be classified into a time bucket: `lt_6h`, `6h_12h`, `12h_24h`, or `gt_24h`
|
||||
- **THEN** the response SHALL include per-station bucket counts and the full lot list
|
||||
|
||||
#### Scenario: QC-GATE data sourced from WIP cache
|
||||
- **WHEN** the API is called
|
||||
- **THEN** the system SHALL read from the existing WIP Redis cache (not direct Oracle query)
|
||||
- **THEN** the response SHALL include `cache_time` indicating the WIP snapshot timestamp
|
||||
|
||||
#### Scenario: No QC-GATE lots in cache
|
||||
- **WHEN** no LOTs match the QC-GATE SPECNAME pattern
|
||||
- **THEN** the system SHALL return an empty `stations` array with `cache_time`
|
||||
|
||||
### Requirement: QC-GATE stations SHALL be ordered by spec sequence
|
||||
The system SHALL order QC-GATE stations according to the manufacturing flow sequence from `DW_MES_SPEC_WORKCENTER_V`.
|
||||
|
||||
#### Scenario: Station ordering
|
||||
- **WHEN** the API returns multiple QC-GATE stations
|
||||
- **THEN** the stations SHALL be sorted by `SPEC_ORDER` from `DW_MES_SPEC_WORKCENTER_V` where `SPEC` matches the SPECNAME
|
||||
|
||||
#### Scenario: Station not found in spec dimension table
|
||||
- **WHEN** a QC-GATE SPECNAME is not found in `DW_MES_SPEC_WORKCENTER_V`
|
||||
- **THEN** the station SHALL appear at the end of the list with a high default sort order
|
||||
|
||||
### Requirement: QC-GATE report page SHALL display stacked bar chart
|
||||
The page SHALL display a stacked bar chart showing LOT counts per QC-GATE station, grouped by wait time bucket.
|
||||
|
||||
#### Scenario: Bar chart rendering
|
||||
- **WHEN** the page loads and data is available
|
||||
- **THEN** the X-axis SHALL show QC-GATE station names
|
||||
- **THEN** the Y-axis SHALL show LOT counts
|
||||
- **THEN** each bar SHALL be stacked with four color-coded segments: <6hr (green), 6-12hr (yellow), 12-24hr (orange), >24hr (red)
|
||||
|
||||
#### Scenario: Empty state
|
||||
- **WHEN** no QC-GATE LOTs exist
|
||||
- **THEN** the chart area SHALL display a "目前無 QC-GATE LOT" message
|
||||
|
||||
### Requirement: QC-GATE report page SHALL display filterable LOT table
|
||||
The page SHALL display a table listing individual LOTs, with click-to-filter interaction from the bar chart.
|
||||
|
||||
#### Scenario: Default table display
|
||||
- **WHEN** the page loads
|
||||
- **THEN** the table SHALL show all QC-GATE LOTs sorted by wait time descending
|
||||
|
||||
#### Scenario: Click bar chart to filter
|
||||
- **WHEN** user clicks a specific segment of a bar (e.g., QC-GATE-DB's 6-12hr segment)
|
||||
- **THEN** the table SHALL filter to show only LOTs matching that station AND time bucket
|
||||
- **THEN** a filter indicator SHALL be visible showing the active filter
|
||||
|
||||
#### Scenario: Clear filter
|
||||
- **WHEN** user clicks the active filter indicator or clicks the same bar segment again
|
||||
- **THEN** the table SHALL return to showing all QC-GATE LOTs
|
||||
|
||||
### Requirement: QC-GATE report page SHALL auto-refresh
|
||||
The page SHALL automatically refresh data at the same interval as the WIP cache update cycle.
|
||||
|
||||
#### Scenario: Auto-refresh while visible
|
||||
- **WHEN** the page is visible and 10 minutes have elapsed since last refresh
|
||||
- **THEN** the page SHALL fetch new data from the API without showing a full loading overlay
|
||||
- **THEN** the chart and table SHALL update with new data
|
||||
|
||||
#### Scenario: Auto-refresh while hidden
|
||||
- **WHEN** the page tab/iframe is hidden (document.hidden === true)
|
||||
- **THEN** the auto-refresh SHALL be skipped
|
||||
|
||||
#### Scenario: Page becomes visible after being hidden
|
||||
- **WHEN** the page becomes visible after being hidden
|
||||
- **THEN** the page SHALL immediately refresh data
|
||||
|
||||
#### Scenario: Manual refresh
|
||||
- **WHEN** user clicks the refresh button
|
||||
- **THEN** the page SHALL fetch new data and reset the auto-refresh timer
|
||||
45
openspec/specs/vue-vite-page-architecture/spec.md
Normal file
45
openspec/specs/vue-vite-page-architecture/spec.md
Normal file
@@ -0,0 +1,45 @@
|
||||
## Purpose
|
||||
Define stable requirements for vue-vite-page-architecture.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
### Requirement: Pure Vite pages SHALL be served as static HTML
|
||||
The system SHALL support serving Vite-built HTML pages directly via Flask without Jinja2 rendering.
|
||||
|
||||
#### Scenario: Serve pure Vite page
|
||||
- **WHEN** user navigates to a pure Vite page route (e.g., `/qc-gate`)
|
||||
- **THEN** Flask SHALL serve the pre-built HTML file from `static/dist/` via `send_from_directory`
|
||||
- **THEN** the HTML SHALL NOT pass through Jinja2 template rendering
|
||||
|
||||
#### Scenario: Page works in portal iframe
|
||||
- **WHEN** the pure Vite page is loaded inside the portal iframe
|
||||
- **THEN** the page SHALL render correctly within the iframe context
|
||||
- **THEN** CSP `frame-ancestors 'self'` SHALL allow the embedding
|
||||
|
||||
### Requirement: Vite config SHALL support Vue SFC and HTML entry points
|
||||
The Vite build configuration SHALL support Vue Single File Components alongside existing vanilla JS entries.
|
||||
|
||||
#### Scenario: Vue plugin coexistence
|
||||
- **WHEN** `vite build` is executed
|
||||
- **THEN** Vue SFC (`.vue` files) SHALL be compiled by `@vitejs/plugin-vue`
|
||||
- **THEN** existing vanilla JS entry points SHALL continue to build without modification
|
||||
|
||||
#### Scenario: HTML entry point
|
||||
- **WHEN** a page uses an HTML file as its Vite entry point
|
||||
- **THEN** Vite SHALL process the HTML and its referenced JS/CSS into `static/dist/`
|
||||
- **THEN** the output SHALL include `<page-name>.html`, `<page-name>.js`, and `<page-name>.css`
|
||||
|
||||
#### Scenario: Chunk splitting
|
||||
- **WHEN** Vite builds the project
|
||||
- **THEN** Vue runtime SHALL be split into a `vendor-vue` chunk
|
||||
- **THEN** ECharts modules SHALL be split into the existing `vendor-echarts` chunk
|
||||
- **THEN** chunk splitting SHALL NOT affect existing page bundles
|
||||
|
||||
### Requirement: Pure Vite pages SHALL handle API calls without legacy MesApi
|
||||
Pure Vite pages SHALL use the existing `frontend/src/core/api.js` module for API communication without depending on the global `window.MesApi` object from `_base.html`.
|
||||
|
||||
#### Scenario: API GET request from pure Vite page
|
||||
- **WHEN** a pure Vite page makes a GET API call
|
||||
- **THEN** the call SHALL use the `apiGet` function from `core/api.js`
|
||||
- **THEN** the call SHALL work without `window.MesApi` being present
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from flask import Flask, jsonify, redirect, render_template, request, session, url_for
|
||||
from flask import Flask, jsonify, redirect, render_template, request, send_from_directory, session, url_for
|
||||
|
||||
from mes_dashboard.config.tables import TABLES_CONFIG
|
||||
from mes_dashboard.config.settings import get_config
|
||||
@@ -413,6 +413,12 @@ def create_app(config_name: str | None = None) -> Flask:
|
||||
"""TMTT printing & lead form defect analysis page."""
|
||||
return render_template('tmtt_defect.html')
|
||||
|
||||
@app.route('/qc-gate')
|
||||
def qc_gate_page():
|
||||
"""QC-GATE status report served as pure Vite HTML output."""
|
||||
dist_dir = os.path.join(app.static_folder or "", "dist")
|
||||
return send_from_directory(dist_dir, 'qc-gate.html')
|
||||
|
||||
# ========================================================
|
||||
# Table Query APIs (for table_data_viewer)
|
||||
# ========================================================
|
||||
|
||||
@@ -15,6 +15,7 @@ from .resource_history_routes import resource_history_bp
|
||||
from .job_query_routes import job_query_bp
|
||||
from .query_tool_routes import query_tool_bp
|
||||
from .tmtt_defect_routes import tmtt_defect_bp
|
||||
from .qc_gate_routes import qc_gate_bp
|
||||
|
||||
|
||||
def register_routes(app) -> None:
|
||||
@@ -28,6 +29,7 @@ def register_routes(app) -> None:
|
||||
app.register_blueprint(job_query_bp)
|
||||
app.register_blueprint(query_tool_bp)
|
||||
app.register_blueprint(tmtt_defect_bp)
|
||||
app.register_blueprint(qc_gate_bp)
|
||||
|
||||
__all__ = [
|
||||
'wip_bp',
|
||||
@@ -41,5 +43,6 @@ __all__ = [
|
||||
'job_query_bp',
|
||||
'query_tool_bp',
|
||||
'tmtt_defect_bp',
|
||||
'qc_gate_bp',
|
||||
'register_routes',
|
||||
]
|
||||
|
||||
19
src/mes_dashboard/routes/qc_gate_routes.py
Normal file
19
src/mes_dashboard/routes/qc_gate_routes.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""QC-GATE API routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from mes_dashboard.services.qc_gate_service import get_qc_gate_summary
|
||||
|
||||
qc_gate_bp = Blueprint('qc_gate', __name__, url_prefix='/api/qc-gate')
|
||||
|
||||
|
||||
@qc_gate_bp.route('/summary')
|
||||
def api_qc_gate_summary():
|
||||
"""Return per-station QC-GATE lot summary from cached WIP data."""
|
||||
result = get_qc_gate_summary()
|
||||
if result is not None:
|
||||
return jsonify({'success': True, 'data': result})
|
||||
return jsonify({'success': False, 'error': '查詢失敗'}), 500
|
||||
@@ -31,6 +31,7 @@ _CACHE = {
|
||||
'workcenter_groups': None, # List of {name, sequence}
|
||||
'workcenter_mapping': None, # Dict {workcentername: {group, sequence}}
|
||||
'workcenter_to_short': None, # Dict {workcentername: short_name}
|
||||
'spec_order_mapping': None, # Dict {spec_name_upper: spec_order}
|
||||
'last_refresh': None,
|
||||
'is_loading': False,
|
||||
}
|
||||
@@ -148,6 +149,19 @@ def get_workcenters_by_group(group_name: str) -> List[str]:
|
||||
]
|
||||
|
||||
|
||||
def get_spec_order_mapping(force_refresh: bool = False) -> Dict[str, int]:
|
||||
"""Get SPEC -> SPEC_ORDER mapping from SPEC_WORKCENTER_V cache.
|
||||
|
||||
Returns:
|
||||
Dict mapping normalized SPEC name (uppercase) to integer SPEC_ORDER.
|
||||
"""
|
||||
_ensure_cache_loaded(force_refresh)
|
||||
mapping = _CACHE.get('spec_order_mapping')
|
||||
if isinstance(mapping, dict):
|
||||
return mapping
|
||||
return {}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Cache Management
|
||||
# ============================================================
|
||||
@@ -166,6 +180,7 @@ def get_cache_status() -> Dict[str, Any]:
|
||||
'is_loading': _CACHE.get('is_loading', False),
|
||||
'workcenter_groups_count': len(_CACHE.get('workcenter_groups') or []),
|
||||
'workcenter_mapping_count': len(_CACHE.get('workcenter_mapping') or {}),
|
||||
'spec_order_mapping_count': len(_CACHE.get('spec_order_mapping') or {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -215,17 +230,20 @@ def _load_cache() -> bool:
|
||||
try:
|
||||
# Load workcenter groups - prioritize SPEC_WORKCENTER_V
|
||||
wc_groups, wc_mapping, wc_short = _load_workcenter_data()
|
||||
spec_order_mapping = _load_spec_order_mapping_from_spec()
|
||||
|
||||
with _CACHE_LOCK:
|
||||
_CACHE['workcenter_groups'] = wc_groups
|
||||
_CACHE['workcenter_mapping'] = wc_mapping
|
||||
_CACHE['workcenter_to_short'] = wc_short
|
||||
_CACHE['spec_order_mapping'] = spec_order_mapping
|
||||
_CACHE['last_refresh'] = datetime.now()
|
||||
_CACHE['is_loading'] = False
|
||||
|
||||
logger.info(
|
||||
f"Filter cache refreshed: {len(wc_groups or [])} groups, "
|
||||
f"{len(wc_mapping or {})} workcenters"
|
||||
f"{len(wc_mapping or {})} workcenters, "
|
||||
f"{len(spec_order_mapping or {})} specs"
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -346,6 +364,60 @@ def _load_workcenter_mapping_from_spec():
|
||||
return [], {}, {}
|
||||
|
||||
|
||||
def _safe_sort_value(value: Any, default: int = 999999) -> int:
|
||||
"""Parse sequence/order values into stable integers."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return default
|
||||
digits = ''.join(ch for ch in text if ch.isdigit())
|
||||
if not digits:
|
||||
return default
|
||||
try:
|
||||
return int(digits)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_spec_name(spec_name: Any) -> str:
|
||||
if spec_name is None:
|
||||
return ''
|
||||
return str(spec_name).strip().upper()
|
||||
|
||||
|
||||
def _load_spec_order_mapping_from_spec() -> Dict[str, int]:
|
||||
"""Load SPEC -> SPEC_ORDER mapping from DW_MES_SPEC_WORKCENTER_V."""
|
||||
try:
|
||||
sql = f"""
|
||||
SELECT DISTINCT
|
||||
SPEC,
|
||||
SPEC_ORDER
|
||||
FROM {SPEC_WORKCENTER_VIEW}
|
||||
WHERE SPEC IS NOT NULL
|
||||
"""
|
||||
df = read_sql_df(sql)
|
||||
if df is None or df.empty:
|
||||
return {}
|
||||
|
||||
mapping: Dict[str, int] = {}
|
||||
for _, row in df.iterrows():
|
||||
normalized_spec = _normalize_spec_name(row.get('SPEC'))
|
||||
if not normalized_spec:
|
||||
continue
|
||||
sort_order = _safe_sort_value(row.get('SPEC_ORDER'))
|
||||
previous = mapping.get(normalized_spec)
|
||||
if previous is None or sort_order < previous:
|
||||
mapping[normalized_spec] = sort_order
|
||||
return mapping
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to load SPEC_ORDER mapping from SPEC_WORKCENTER_V: {exc}")
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_workcenter_data_from_df(df):
|
||||
"""Extract workcenter groups and mapping from DataFrame.
|
||||
|
||||
|
||||
247
src/mes_dashboard/services/qc_gate_service.py
Normal file
247
src/mes_dashboard/services/qc_gate_service.py
Normal file
@@ -0,0 +1,247 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""QC-GATE summary service built from cached WIP data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from mes_dashboard.core.cache import (
|
||||
get_cached_wip_data,
|
||||
get_cached_sys_date,
|
||||
get_cache_updated_at,
|
||||
)
|
||||
from mes_dashboard.services.filter_cache import get_spec_order_mapping
|
||||
|
||||
logger = logging.getLogger('mes_dashboard.qc_gate_service')
|
||||
|
||||
_DEFAULT_SPEC_ORDER = 999999
|
||||
_BUCKET_TEMPLATE = {
|
||||
'lt_6h': 0,
|
||||
'6h_12h': 0,
|
||||
'12h_24h': 0,
|
||||
'gt_24h': 0,
|
||||
}
|
||||
|
||||
|
||||
def _safe_value(value: Any) -> Any:
|
||||
"""Normalize pandas NaN/NaT values to None."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
if pd.isna(value):
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(value, 'item'):
|
||||
try:
|
||||
return value.item()
|
||||
except Exception:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _safe_int(value: Any, default: int = 0) -> int:
|
||||
value = _safe_value(value)
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> Optional[float]:
|
||||
value = _safe_value(value)
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_text(value: Any) -> str:
|
||||
value = _safe_value(value)
|
||||
if value is None:
|
||||
return ''
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _normalize_spec(spec_name: Any) -> str:
|
||||
return _normalize_text(spec_name).upper()
|
||||
|
||||
|
||||
def _classify_wait_bucket(wait_hours: float) -> str:
|
||||
if wait_hours < 6:
|
||||
return 'lt_6h'
|
||||
if wait_hours < 12:
|
||||
return '6h_12h'
|
||||
if wait_hours < 24:
|
||||
return '12h_24h'
|
||||
return 'gt_24h'
|
||||
|
||||
|
||||
def _resolve_reference_time(cache_time: Optional[str], df: pd.DataFrame) -> Optional[pd.Timestamp]:
|
||||
ts = pd.to_datetime(cache_time, errors='coerce')
|
||||
if pd.notna(ts):
|
||||
return ts
|
||||
|
||||
if 'SYS_DATE' in df.columns:
|
||||
sys_dates = pd.to_datetime(df['SYS_DATE'], errors='coerce')
|
||||
if not sys_dates.empty:
|
||||
max_ts = sys_dates.max()
|
||||
if pd.notna(max_ts):
|
||||
return max_ts
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_move_in_time(row: pd.Series) -> Optional[pd.Timestamp]:
|
||||
for column in ('MOVEINTIMESTAMP', 'TRACKINTIMESTAMP', 'LOTTRACKINTIME', 'STARTDATE'):
|
||||
if column not in row.index:
|
||||
continue
|
||||
ts = pd.to_datetime(row.get(column), errors='coerce')
|
||||
if pd.notna(ts):
|
||||
return ts
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_wait_hours(row: pd.Series, reference_time: Optional[pd.Timestamp], move_in_time: Optional[pd.Timestamp]) -> float:
|
||||
if reference_time is not None and move_in_time is not None:
|
||||
delta = (reference_time - move_in_time).total_seconds() / 3600.0
|
||||
if delta >= 0:
|
||||
return float(delta)
|
||||
|
||||
age_days = _safe_float(row.get('AGEBYDAYS'))
|
||||
if age_days is not None and age_days >= 0:
|
||||
return float(age_days * 24)
|
||||
|
||||
return 0.0
|
||||
|
||||
|
||||
def _derive_wip_status(row: pd.Series) -> str:
|
||||
direct = _normalize_text(row.get('WIP_STATUS') or row.get('STATUS'))
|
||||
if direct:
|
||||
return direct.upper()
|
||||
|
||||
equipment_count = _safe_int(row.get('EQUIPMENTCOUNT'))
|
||||
hold_count = _safe_int(row.get('CURRENTHOLDCOUNT'))
|
||||
if equipment_count > 0:
|
||||
return 'RUN'
|
||||
if hold_count > 0:
|
||||
return 'HOLD'
|
||||
return 'QUEUE'
|
||||
|
||||
|
||||
def _build_lot_payload(row: pd.Series, reference_time: Optional[pd.Timestamp]) -> Dict[str, Any]:
|
||||
move_in_time = _resolve_move_in_time(row)
|
||||
wait_hours = _resolve_wait_hours(row, reference_time, move_in_time)
|
||||
bucket = _classify_wait_bucket(wait_hours)
|
||||
|
||||
move_in_display = None
|
||||
if move_in_time is not None:
|
||||
move_in_display = move_in_time.isoformat()
|
||||
|
||||
step = _normalize_text(row.get('SPECNAME'))
|
||||
lot_id = _safe_value(row.get('LOTID') or row.get('CONTAINERNAME'))
|
||||
container_id = _safe_value(row.get('CONTAINERID') or row.get('CONTAINERNAME') or lot_id)
|
||||
|
||||
product = (
|
||||
_safe_value(row.get('PRODUCT'))
|
||||
or _safe_value(row.get('PACKAGE_LEF'))
|
||||
or _safe_value(row.get('PRODUCTLINENAME'))
|
||||
)
|
||||
|
||||
return {
|
||||
'lot_id': lot_id,
|
||||
'container_id': container_id,
|
||||
'product': product,
|
||||
'qty': _safe_int(row.get('QTY')),
|
||||
'step': step,
|
||||
'workorder': _safe_value(row.get('WORKORDER')),
|
||||
'move_in_time': move_in_display,
|
||||
'wait_hours': round(wait_hours, 2),
|
||||
'bucket': bucket,
|
||||
'status': _derive_wip_status(row),
|
||||
'equipment': _safe_value(row.get('EQUIPMENTS') or row.get('EQUIPMENTNAME')),
|
||||
}
|
||||
|
||||
|
||||
def get_qc_gate_summary() -> Optional[Dict[str, Any]]:
|
||||
"""Get QC-GATE lot summary from Redis-cached WIP snapshot.
|
||||
|
||||
Returns:
|
||||
Dict with cache_time and per-station lot summary, or None on failure.
|
||||
"""
|
||||
cache_time = get_cached_sys_date() or get_cache_updated_at()
|
||||
|
||||
try:
|
||||
df = get_cached_wip_data()
|
||||
if df is None or df.empty or 'SPECNAME' not in df.columns:
|
||||
return {
|
||||
'cache_time': cache_time,
|
||||
'stations': [],
|
||||
}
|
||||
|
||||
spec_series = df['SPECNAME'].fillna('').astype(str).str.upper()
|
||||
qc_gate_mask = spec_series.str.contains('QC', na=False) & spec_series.str.contains('GATE', na=False)
|
||||
qc_gate_df = df[qc_gate_mask].copy()
|
||||
|
||||
if qc_gate_df.empty:
|
||||
return {
|
||||
'cache_time': cache_time,
|
||||
'stations': [],
|
||||
}
|
||||
|
||||
reference_time = _resolve_reference_time(cache_time, qc_gate_df)
|
||||
spec_order_mapping = get_spec_order_mapping() or {}
|
||||
|
||||
stations_by_spec: Dict[str, Dict[str, Any]] = {}
|
||||
for _, row in qc_gate_df.iterrows():
|
||||
spec_name = _normalize_text(row.get('SPECNAME'))
|
||||
if not spec_name:
|
||||
continue
|
||||
|
||||
normalized_spec = _normalize_spec(spec_name)
|
||||
spec_order = int(spec_order_mapping.get(normalized_spec, _DEFAULT_SPEC_ORDER))
|
||||
lot_payload = _build_lot_payload(row, reference_time)
|
||||
|
||||
station = stations_by_spec.get(spec_name)
|
||||
if station is None:
|
||||
station = {
|
||||
'specname': spec_name,
|
||||
'spec_order': spec_order,
|
||||
'buckets': dict(_BUCKET_TEMPLATE),
|
||||
'total': 0,
|
||||
'lots': [],
|
||||
}
|
||||
stations_by_spec[spec_name] = station
|
||||
|
||||
station['buckets'][lot_payload['bucket']] += 1
|
||||
station['total'] += 1
|
||||
station['lots'].append(lot_payload)
|
||||
|
||||
stations = list(stations_by_spec.values())
|
||||
for station in stations:
|
||||
station['lots'].sort(
|
||||
key=lambda lot: float(lot.get('wait_hours') or 0),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
stations.sort(
|
||||
key=lambda station: (
|
||||
int(station.get('spec_order', _DEFAULT_SPEC_ORDER)),
|
||||
station.get('specname', ''),
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
'cache_time': cache_time,
|
||||
'stations': stations,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.exception('Failed to build QC-GATE summary: %s', exc)
|
||||
return None
|
||||
71
tests/test_qc_gate_routes.py
Normal file
71
tests/test_qc_gate_routes.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for QC-GATE API and page routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import mes_dashboard.core.database as db
|
||||
from flask import Response
|
||||
|
||||
from mes_dashboard.app import create_app
|
||||
|
||||
|
||||
def _client():
|
||||
db._ENGINE = None
|
||||
app = create_app('testing')
|
||||
app.config['TESTING'] = True
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@patch('mes_dashboard.routes.qc_gate_routes.get_qc_gate_summary')
|
||||
def test_qc_gate_summary_route_returns_success(mock_get_summary):
|
||||
mock_get_summary.return_value = {
|
||||
'cache_time': '2026-02-09T12:00:00',
|
||||
'stations': [
|
||||
{
|
||||
'specname': 'QC-GATE-A',
|
||||
'spec_order': 10,
|
||||
'buckets': {
|
||||
'lt_6h': 1,
|
||||
'6h_12h': 0,
|
||||
'12h_24h': 0,
|
||||
'gt_24h': 0,
|
||||
},
|
||||
'total': 1,
|
||||
'lots': [],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
response = _client().get('/api/qc-gate/summary')
|
||||
payload = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert payload['success'] is True
|
||||
assert payload['data']['cache_time'] == '2026-02-09T12:00:00'
|
||||
assert payload['data']['stations'][0]['specname'] == 'QC-GATE-A'
|
||||
|
||||
|
||||
@patch('mes_dashboard.routes.qc_gate_routes.get_qc_gate_summary', return_value=None)
|
||||
def test_qc_gate_summary_route_returns_500_on_failure(_mock_get_summary):
|
||||
response = _client().get('/api/qc-gate/summary')
|
||||
payload = response.get_json()
|
||||
|
||||
assert response.status_code == 500
|
||||
assert payload['success'] is False
|
||||
assert 'error' in payload
|
||||
|
||||
|
||||
@patch('mes_dashboard.app.send_from_directory')
|
||||
def test_qc_gate_page_served_from_static_dist(mock_send_from_directory):
|
||||
mock_send_from_directory.return_value = Response('<html>ok</html>', mimetype='text/html')
|
||||
|
||||
response = _client().get('/qc-gate')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'text/html' in response.content_type
|
||||
|
||||
call_args = mock_send_from_directory.call_args[0]
|
||||
assert call_args[0].endswith('/static/dist')
|
||||
assert call_args[1] == 'qc-gate.html'
|
||||
150
tests/test_qc_gate_service.py
Normal file
150
tests/test_qc_gate_service.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for QC-GATE summary service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from mes_dashboard.services.qc_gate_service import get_qc_gate_summary
|
||||
|
||||
|
||||
@patch('mes_dashboard.services.qc_gate_service.get_spec_order_mapping')
|
||||
@patch('mes_dashboard.services.qc_gate_service.get_cache_updated_at')
|
||||
@patch('mes_dashboard.services.qc_gate_service.get_cached_sys_date')
|
||||
@patch('mes_dashboard.services.qc_gate_service.get_cached_wip_data')
|
||||
def test_get_qc_gate_summary_filters_classifies_and_orders(
|
||||
mock_get_wip,
|
||||
mock_get_sys_date,
|
||||
mock_get_cache_updated_at,
|
||||
mock_get_spec_order_mapping,
|
||||
):
|
||||
mock_get_sys_date.return_value = '2026-02-09T12:00:00'
|
||||
mock_get_cache_updated_at.return_value = '2026-02-09T12:00:00'
|
||||
mock_get_spec_order_mapping.return_value = {
|
||||
'QC-GATE-A': 10,
|
||||
'QC-GATE-B': 20,
|
||||
}
|
||||
|
||||
mock_get_wip.return_value = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
'LOTID': 'L-001',
|
||||
'CONTAINERID': 'C-001',
|
||||
'SPECNAME': 'QC-GATE-B',
|
||||
'MOVEINTIMESTAMP': '2026-02-09T09:00:00',
|
||||
'QTY': 100,
|
||||
'WORKORDER': 'WO-1',
|
||||
'STATUS': 'QUEUE',
|
||||
'EQUIPMENTS': None,
|
||||
},
|
||||
{
|
||||
'LOTID': 'L-002',
|
||||
'CONTAINERID': 'C-002',
|
||||
'SPECNAME': 'QC-GATE-A',
|
||||
'MOVEINTIMESTAMP': '2026-02-09T11:00:00',
|
||||
'QTY': 200,
|
||||
'WORKORDER': 'WO-2',
|
||||
'STATUS': 'QUEUE',
|
||||
'EQUIPMENTS': None,
|
||||
},
|
||||
{
|
||||
'LOTID': 'L-003',
|
||||
'CONTAINERID': 'C-003',
|
||||
'SPECNAME': 'QC-GATE-A',
|
||||
'MOVEINTIMESTAMP': '2026-02-08T08:00:00',
|
||||
'QTY': 50,
|
||||
'WORKORDER': 'WO-3',
|
||||
'STATUS': 'HOLD',
|
||||
'EQUIPMENTS': None,
|
||||
},
|
||||
{
|
||||
'LOTID': 'L-004',
|
||||
'CONTAINERID': 'C-004',
|
||||
'SPECNAME': 'ASSEMBLY-STEP',
|
||||
'MOVEINTIMESTAMP': '2026-02-09T10:00:00',
|
||||
'QTY': 25,
|
||||
'WORKORDER': 'WO-4',
|
||||
'STATUS': 'QUEUE',
|
||||
'EQUIPMENTS': None,
|
||||
},
|
||||
{
|
||||
'LOTID': 'L-005',
|
||||
'CONTAINERID': 'C-005',
|
||||
'SPECNAME': 'QC-LATE-GATE',
|
||||
'MOVEINTIMESTAMP': '2026-02-07T06:00:00',
|
||||
'QTY': 75,
|
||||
'WORKORDER': 'WO-5',
|
||||
'STATUS': 'QUEUE',
|
||||
'EQUIPMENTS': None,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
result = get_qc_gate_summary()
|
||||
|
||||
assert result is not None
|
||||
assert result['cache_time'] == '2026-02-09T12:00:00'
|
||||
|
||||
stations = result['stations']
|
||||
assert [station['specname'] for station in stations] == [
|
||||
'QC-GATE-A',
|
||||
'QC-GATE-B',
|
||||
'QC-LATE-GATE',
|
||||
]
|
||||
|
||||
station_a = stations[0]
|
||||
assert station_a['buckets']['lt_6h'] == 1
|
||||
assert station_a['buckets']['gt_24h'] == 1
|
||||
assert station_a['total'] == 2
|
||||
|
||||
station_b = stations[1]
|
||||
assert station_b['buckets']['lt_6h'] == 1
|
||||
assert station_b['total'] == 1
|
||||
|
||||
unknown_station = stations[2]
|
||||
assert unknown_station['spec_order'] == 999999
|
||||
assert unknown_station['total'] == 1
|
||||
|
||||
|
||||
@patch('mes_dashboard.services.qc_gate_service.get_spec_order_mapping', return_value={})
|
||||
@patch('mes_dashboard.services.qc_gate_service.get_cache_updated_at', return_value='2026-02-09T12:00:00')
|
||||
@patch('mes_dashboard.services.qc_gate_service.get_cached_sys_date', return_value=None)
|
||||
@patch('mes_dashboard.services.qc_gate_service.get_cached_wip_data')
|
||||
def test_get_qc_gate_summary_returns_empty_when_no_match(
|
||||
mock_get_wip,
|
||||
_mock_get_sys_date,
|
||||
_mock_get_cache_updated_at,
|
||||
_mock_get_spec_order_mapping,
|
||||
):
|
||||
mock_get_wip.return_value = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
'LOTID': 'L-100',
|
||||
'SPECNAME': 'ASSEMBLY-STEP',
|
||||
'MOVEINTIMESTAMP': '2026-02-09T10:00:00',
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
result = get_qc_gate_summary()
|
||||
|
||||
assert result is not None
|
||||
assert result['cache_time'] == '2026-02-09T12:00:00'
|
||||
assert result['stations'] == []
|
||||
|
||||
|
||||
@patch('mes_dashboard.services.qc_gate_service.get_cache_updated_at', return_value='2026-02-09T12:00:00')
|
||||
@patch('mes_dashboard.services.qc_gate_service.get_cached_sys_date', return_value=None)
|
||||
@patch('mes_dashboard.services.qc_gate_service.get_cached_wip_data', return_value=None)
|
||||
def test_get_qc_gate_summary_returns_empty_when_cache_missing(
|
||||
_mock_get_wip,
|
||||
_mock_get_sys_date,
|
||||
_mock_get_cache_updated_at,
|
||||
):
|
||||
result = get_qc_gate_summary()
|
||||
|
||||
assert result is not None
|
||||
assert result['cache_time'] == '2026-02-09T12:00:00'
|
||||
assert result['stations'] == []
|
||||
Reference in New Issue
Block a user