feat: finalize portal no-iframe migration baseline and archive change
This commit is contained in:
33
docs/migration/portal-no-iframe/archive_readiness.md
Normal file
33
docs/migration/portal-no-iframe/archive_readiness.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# OpenSpec Archive Readiness (`portal-no-iframe-navigation`)
|
||||
|
||||
## Spec Sync Scope
|
||||
|
||||
Main specs synchronized/updated for this change:
|
||||
|
||||
- `openspec/specs/full-vite-page-modularization/spec.md`
|
||||
- `openspec/specs/portal-drawer-navigation/spec.md`
|
||||
- `openspec/specs/vue-vite-page-architecture/spec.md`
|
||||
- `openspec/specs/migration-gates-and-rollout/spec.md`
|
||||
- `openspec/specs/spa-shell-navigation/spec.md` (new)
|
||||
- `openspec/specs/tailwind-design-system/spec.md` (new)
|
||||
- `openspec/specs/frontend-motion-system/spec.md` (new)
|
||||
- `openspec/specs/legacy-page-wrapper-strategy/spec.md` (new)
|
||||
|
||||
## Migration Closure Artifacts
|
||||
|
||||
- Rewrite smoke checklist:
|
||||
- `docs/migration/portal-no-iframe/legacy_rewrite_smoke_checklists.md`
|
||||
- Rewrite exemplar:
|
||||
- `docs/migration/portal-no-iframe/tmtt_rewrite_exemplar.md`
|
||||
- Rewrite playbook:
|
||||
- `docs/migration/portal-no-iframe/legacy_rewrite_playbook.md`
|
||||
- Wrapper decommission record:
|
||||
- `docs/migration/portal-no-iframe/wrapper_decommission_report.md`
|
||||
- Frame field retirement record:
|
||||
- `docs/migration/portal-no-iframe/frame_id_tool_src_deprecation_plan.md`
|
||||
|
||||
## Pre-Archive Checklist
|
||||
|
||||
- [x] `openspec validate portal-no-iframe-navigation --strict` passes.
|
||||
- [x] Build and core migration tests pass on latest branch.
|
||||
- [x] Task list in `openspec/changes/portal-no-iframe-navigation/tasks.md` is fully checked.
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"source": "current frontend API consumption contracts",
|
||||
"apis": {
|
||||
"/api/wip/overview/summary": {
|
||||
"required_keys": [
|
||||
"dataUpdateDate",
|
||||
"runLots",
|
||||
"queueLots",
|
||||
"holdLots"
|
||||
],
|
||||
"notes": "summary header and cards depend on these fields"
|
||||
},
|
||||
"/api/wip/overview/matrix": {
|
||||
"required_keys": [
|
||||
"workcenters",
|
||||
"packages",
|
||||
"matrix",
|
||||
"workcenter_totals"
|
||||
],
|
||||
"notes": "matrix table rendering contract"
|
||||
},
|
||||
"/api/wip/hold-detail/summary": {
|
||||
"required_keys": [
|
||||
"workcenterCount",
|
||||
"packageCount",
|
||||
"lotCount"
|
||||
],
|
||||
"notes": "hold detail summary cards contract"
|
||||
},
|
||||
"/api/resource/history/summary": {
|
||||
"required_keys": [
|
||||
"kpi",
|
||||
"trend",
|
||||
"heatmap",
|
||||
"workcenter_comparison"
|
||||
],
|
||||
"notes": "resource history chart summary contract"
|
||||
},
|
||||
"/api/resource/history/detail": {
|
||||
"required_keys": [
|
||||
"data"
|
||||
],
|
||||
"notes": "detail table contract (plus truncated/max_records metadata when present)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"source": "data/page_status.json",
|
||||
"errors": []
|
||||
}
|
||||
177
docs/migration/portal-no-iframe/baseline_drawer_visibility.json
Normal file
177
docs/migration/portal-no-iframe/baseline_drawer_visibility.json
Normal file
@@ -0,0 +1,177 @@
|
||||
{
|
||||
"source": "data/page_status.json",
|
||||
"admin": [
|
||||
{
|
||||
"id": "reports",
|
||||
"name": "即時報表",
|
||||
"order": 1,
|
||||
"admin_only": false,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/wip-overview",
|
||||
"name": "WIP 即時概況",
|
||||
"status": "released",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"route": "/hold-overview",
|
||||
"name": "Hold 即時概況",
|
||||
"status": "dev",
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"route": "/resource",
|
||||
"name": "設備即時概況",
|
||||
"status": "released",
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"route": "/qc-gate",
|
||||
"name": "QC-GATE 狀態",
|
||||
"status": "released",
|
||||
"order": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "drawer-2",
|
||||
"name": "歷史報表",
|
||||
"order": 2,
|
||||
"admin_only": false,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/hold-history",
|
||||
"name": "Hold 歷史績效",
|
||||
"status": "dev",
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"route": "/resource-history",
|
||||
"name": "設備歷史績效",
|
||||
"status": "released",
|
||||
"order": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "drawer",
|
||||
"name": "查詢工具",
|
||||
"order": 3,
|
||||
"admin_only": false,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/job-query",
|
||||
"name": "設備維修查詢",
|
||||
"status": "released",
|
||||
"order": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dev-tools",
|
||||
"name": "開發工具",
|
||||
"order": 4,
|
||||
"admin_only": true,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/tables",
|
||||
"name": "表格總覽",
|
||||
"status": "dev",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"route": "/admin/pages",
|
||||
"name": "頁面管理",
|
||||
"status": "dev",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"route": "/excel-query",
|
||||
"name": "Excel 批次查詢",
|
||||
"status": "dev",
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"route": "/admin/performance",
|
||||
"name": "效能監控",
|
||||
"status": "dev",
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"route": "/query-tool",
|
||||
"name": "批次追蹤工具",
|
||||
"status": "dev",
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"route": "/tmtt-defect",
|
||||
"name": "TMTT印字腳型不良分析",
|
||||
"status": "released",
|
||||
"order": 5
|
||||
},
|
||||
{
|
||||
"route": "/mid-section-defect",
|
||||
"name": "中段製程不良追溯",
|
||||
"status": "dev",
|
||||
"order": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"non_admin": [
|
||||
{
|
||||
"id": "reports",
|
||||
"name": "即時報表",
|
||||
"order": 1,
|
||||
"admin_only": false,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/wip-overview",
|
||||
"name": "WIP 即時概況",
|
||||
"status": "released",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"route": "/resource",
|
||||
"name": "設備即時概況",
|
||||
"status": "released",
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"route": "/qc-gate",
|
||||
"name": "QC-GATE 狀態",
|
||||
"status": "released",
|
||||
"order": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "drawer-2",
|
||||
"name": "歷史報表",
|
||||
"order": 2,
|
||||
"admin_only": false,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/resource-history",
|
||||
"name": "設備歷史績效",
|
||||
"status": "released",
|
||||
"order": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "drawer",
|
||||
"name": "查詢工具",
|
||||
"order": 3,
|
||||
"admin_only": false,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/job-query",
|
||||
"name": "設備維修查詢",
|
||||
"status": "released",
|
||||
"order": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"source": "frontend route parsing and current parity matrix",
|
||||
"routes": {
|
||||
"/wip-overview": {
|
||||
"query_keys": [
|
||||
"workorder",
|
||||
"lotid",
|
||||
"package",
|
||||
"type",
|
||||
"status"
|
||||
],
|
||||
"notes": "filters + status URL state must remain compatible"
|
||||
},
|
||||
"/wip-detail": {
|
||||
"query_keys": [
|
||||
"workcenter",
|
||||
"workorder",
|
||||
"lotid",
|
||||
"package",
|
||||
"type",
|
||||
"status"
|
||||
],
|
||||
"notes": "workcenter deep-link and back-link query continuity"
|
||||
},
|
||||
"/hold-detail": {
|
||||
"query_keys": [
|
||||
"reason"
|
||||
],
|
||||
"notes": "reason required for normal access flow"
|
||||
},
|
||||
"/resource-history": {
|
||||
"query_keys": [
|
||||
"start_date",
|
||||
"end_date",
|
||||
"granularity",
|
||||
"workcenter_groups",
|
||||
"families",
|
||||
"resource_ids",
|
||||
"is_production",
|
||||
"is_key",
|
||||
"is_monitor"
|
||||
],
|
||||
"notes": "query/export params must remain compatible"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Drawer Governance Contract (Portal No-Iframe Migration)
|
||||
|
||||
## Scope
|
||||
|
||||
This contract defines drawer behavior that must remain stable during migration.
|
||||
|
||||
## Canonical Responsibilities
|
||||
|
||||
Drawer metadata is responsible for:
|
||||
|
||||
- Information architecture grouping.
|
||||
- Display order.
|
||||
- Access visibility (`admin_only`).
|
||||
|
||||
Drawer metadata is not responsible for:
|
||||
|
||||
- Content embedding mode (`iframe`, `toolFrame`).
|
||||
- Rendering technology selection (Jinja vs SPA route view).
|
||||
|
||||
## Contract Rules
|
||||
|
||||
1. Drawer IDs must be unique and non-empty.
|
||||
2. Page routes must be unique and non-empty.
|
||||
3. `page.drawer_id` (when present) must reference an existing drawer.
|
||||
4. `order` values (when present) must be positive integers.
|
||||
5. Page status must be one of `released` or `dev`.
|
||||
6. Visibility outcomes must be deterministic for admin/non-admin users.
|
||||
|
||||
## Deterministic Rendering Order
|
||||
|
||||
Drawers:
|
||||
|
||||
- Primary sort by `order` ascending.
|
||||
- Secondary sort by `name` ascending.
|
||||
|
||||
Pages in each drawer:
|
||||
|
||||
- Primary sort by `order` ascending.
|
||||
- Secondary sort by `(name or route)` ascending.
|
||||
|
||||
## Visibility Semantics
|
||||
|
||||
- Non-admin users can view only `released` pages in non-admin-only drawers.
|
||||
- Admin users can view all drawer-assigned pages according to current page status policy.
|
||||
- Drawers with zero visible pages are hidden.
|
||||
|
||||
## Validation Artifacts
|
||||
|
||||
- `baseline_drawer_contract_validation.json`
|
||||
- `baseline_drawer_visibility.json`
|
||||
@@ -0,0 +1,44 @@
|
||||
# `frame_id` / `tool_src` Deprecation Plan
|
||||
|
||||
## Status
|
||||
|
||||
- Retirement completed in this change.
|
||||
- Runtime navigation payload generation no longer emits:
|
||||
- `frame_id`
|
||||
- `tool_src`
|
||||
|
||||
## Context
|
||||
|
||||
Frame-era fields were used for iframe loading compatibility:
|
||||
|
||||
- `frame_id`
|
||||
- `tool_src`
|
||||
|
||||
## Policy
|
||||
|
||||
Deprecation is phased and must not break active routes.
|
||||
|
||||
## Phases
|
||||
|
||||
1. **Compatibility phase**:
|
||||
- Keep fields in payload.
|
||||
- Ensure new router navigation logic does not rely on these fields.
|
||||
2. **Dual-run phase**:
|
||||
- Validate all navigation paths without frame fields.
|
||||
3. **Retirement readiness**:
|
||||
- Wrapper-first pages are stable in shell.
|
||||
- Cutover gates G1~G7 are green in rehearsal.
|
||||
4. **Removal phase**:
|
||||
- Remove generation and downstream usage of `frame_id/tool_src`.
|
||||
- Update related tests and docs.
|
||||
|
||||
## Removal Checkpoints
|
||||
|
||||
- Checkpoint A: drawer parity stable in canary.
|
||||
- Checkpoint B: legacy wrappers stable with no frame-field dependency.
|
||||
- Checkpoint C: rollback mechanism verified independent of frame fields. ✓
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- Keep rollback-safe path via route-level navigation and kill-switch.
|
||||
- Keep gate coverage for route/drawer/workflow parity after field removal.
|
||||
40
docs/migration/portal-no-iframe/legacy_rewrite_playbook.md
Normal file
40
docs/migration/portal-no-iframe/legacy_rewrite_playbook.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Legacy Rewrite Playbook (Batch-2)
|
||||
|
||||
## Target Pages
|
||||
|
||||
This playbook governs rewrite execution for the remaining three legacy pages:
|
||||
|
||||
- `/job-query`
|
||||
- `/excel-query`
|
||||
- `/query-tool`
|
||||
|
||||
Rewrite order follows `legacy_rewrite_priority_matrix.md`.
|
||||
|
||||
## Canonical Steps
|
||||
|
||||
1. Preserve route and API contracts first.
|
||||
2. Move page state and API calls into composables (`use<Page>Data`).
|
||||
3. Replace page-local repeated blocks with shared UI components where possible.
|
||||
4. Keep Tailwind token alignment for new/changed UI.
|
||||
5. Validate with per-page smoke checklist before/after switch.
|
||||
|
||||
## Required Acceptance (Per Page)
|
||||
|
||||
- Route reachable and functional without shell wrapper.
|
||||
- Core query workflow succeeds and returns expected result sections.
|
||||
- Export path remains usable (where applicable).
|
||||
- No new unhandled runtime error on the primary path.
|
||||
- Checklist IDs pass:
|
||||
- `/job-query`: `JOB-SMOKE-01`~`JOB-SMOKE-06`
|
||||
- `/excel-query`: `EXCEL-SMOKE-01`~`EXCEL-SMOKE-06`
|
||||
- `/query-tool`: `QTOOL-SMOKE-01`~`QTOOL-SMOKE-06`
|
||||
|
||||
Checklist source:
|
||||
|
||||
- `docs/migration/portal-no-iframe/legacy_rewrite_smoke_checklists.md`
|
||||
|
||||
## Shared Guardrails
|
||||
|
||||
- Do not change backend API signatures in rewrite phase.
|
||||
- Keep direct-link behavior and query semantics stable.
|
||||
- If parity fails, rollback to previous stable page artifact before continuing.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Legacy Rewrite Priority Matrix
|
||||
|
||||
## Scoring model
|
||||
|
||||
`priority_score = usage(0-5)*0.3 + complexity(0-5)*0.4 + risk(0-5)*0.3`
|
||||
|
||||
- Usage: current observed operational usage + business criticality.
|
||||
- Complexity: route/API count, frontend LOC, workflow branches.
|
||||
- Risk: data mutation/export/upload sensitivity and regression blast radius.
|
||||
|
||||
## Measured technical baseline
|
||||
|
||||
| Page | Backend route LOC | Template LOC | Frontend LOC | API surface |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| `query-tool` | 509 | 1267 | 3139 | resolve/history/adjacent/associations/equipment/export |
|
||||
| `excel-query` | 355 | 1181 | 624 | upload/schema/query/export |
|
||||
| `job-query` | 195 | 995 | 520 | resources/jobs/txn/export |
|
||||
| `tmtt-defect` | 82 | 271 | 363 | analysis/query + CSV export |
|
||||
|
||||
## Priority scoring
|
||||
|
||||
| Page | Usage | Complexity | Risk | Score |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| `tmtt-defect` | 2 | 1 | 2 | 1.6 |
|
||||
| `job-query` | 3 | 2 | 3 | 2.6 |
|
||||
| `excel-query` | 3 | 4 | 4 | 3.7 |
|
||||
| `query-tool` | 4 | 5 | 5 | 4.7 |
|
||||
|
||||
## Rewrite order decision
|
||||
|
||||
1. `tmtt-defect` (canonical exemplar)
|
||||
2. `job-query`
|
||||
3. `excel-query`
|
||||
4. `query-tool`
|
||||
|
||||
## Rationale
|
||||
|
||||
- Start with lowest-complexity page to establish shared migration playbook.
|
||||
- Keep high-complexity/high-risk `query-tool` last to maximize reuse from prior rewrites.
|
||||
- Defer upload-heavy `excel-query` until shared error/retry/upload patterns are stabilized.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Legacy Rewrite Smoke Checklists (Per-Page)
|
||||
|
||||
本文件是 `7.2 ~ 7.4` 的執行前置與驗收基準。
|
||||
每一頁在「重寫前(wrapper baseline)」與「重寫後(rewrite candidate)」都必須執行同一組 smoke。
|
||||
|
||||
## 0. 執行規則
|
||||
|
||||
- 必須記錄:執行日期、分支/commit、執行人、環境(DEV/UAT)。
|
||||
- 每頁 smoke 通過率要求:`100%`。
|
||||
- 任何 P0 smoke 失敗即視為 `No-Go`,不得進入 wrapper 移除。
|
||||
- `excel-query`、`query-tool` 為 admin/dev 可見頁,需使用 admin 身份執行。
|
||||
|
||||
## 1. `tmtt-defect`(Rewrite Exemplar)
|
||||
|
||||
### 前置條件
|
||||
- 可取得有效 `start_date/end_date` 測試區間。
|
||||
- `/api/tmtt-defect/analysis` 與 `/api/tmtt-defect/export` 可連線。
|
||||
|
||||
### Smoke Cases
|
||||
- [ ] `TMTT-SMOKE-01` Route reachable: `/tmtt-defect` 可直接開啟,無白屏/JS error。
|
||||
- [ ] `TMTT-SMOKE-02` Required params guard: 缺少日期時,顯示明確錯誤且不崩潰。
|
||||
- [ ] `TMTT-SMOKE-03` Query success: 送出合法日期後,KPI/Charts/Detail 皆成功渲染。
|
||||
- [ ] `TMTT-SMOKE-04` Drill-down: 點擊 Pareto 圖欄位可套用/清除篩選,明細同步。
|
||||
- [ ] `TMTT-SMOKE-05` Table behavior: 明細表格可排序,排序方向切換正確。
|
||||
- [ ] `TMTT-SMOKE-06` Export CSV: 匯出成功,response 為 CSV 且檔名包含日期區間。
|
||||
|
||||
## 2. `job-query`
|
||||
|
||||
### 前置條件
|
||||
- `resource` 清單可取得。
|
||||
- 至少有一組可查詢日期區間。
|
||||
|
||||
### Smoke Cases
|
||||
- [ ] `JOB-SMOKE-01` Route reachable: `/job-query` 可直接開啟。
|
||||
- [ ] `JOB-SMOKE-02` Resource loading: `/api/job-query/resources` 回傳清單,UI 可選取。
|
||||
- [ ] `JOB-SMOKE-03` Query jobs: 選設備+日期後可查詢成功並顯示結果。
|
||||
- [ ] `JOB-SMOKE-04` Txn detail: 由查詢結果可開啟某筆 job 的 txn history。
|
||||
- [ ] `JOB-SMOKE-05` Export CSV: 匯出成功且檔案可下載。
|
||||
- [ ] `JOB-SMOKE-06` Validation: 缺日期/無設備/超過上限時回傳明確錯誤訊息。
|
||||
|
||||
## 3. `excel-query`(Admin)
|
||||
|
||||
### 前置條件
|
||||
- 準備一份有效 `.xlsx` 測試檔。
|
||||
- Admin session 已登入。
|
||||
|
||||
### Smoke Cases
|
||||
- [ ] `EXCEL-SMOKE-01` Route/auth: `/excel-query` admin 可進入,非 admin 受保護。
|
||||
- [ ] `EXCEL-SMOKE-02` Upload: 上傳有效 Excel 後可解析欄位與預覽。
|
||||
- [ ] `EXCEL-SMOKE-03` Column detect: 欄位唯一值與型別偵測可正常運作。
|
||||
- [ ] `EXCEL-SMOKE-04` Execute query: 標準查詢與進階查詢都可回傳資料。
|
||||
- [ ] `EXCEL-SMOKE-05` Export CSV: 查詢結果可匯出 CSV。
|
||||
- [ ] `EXCEL-SMOKE-06` Invalid file guard: 非 `.xls/.xlsx` 檔案被拒絕且回傳可讀錯誤。
|
||||
|
||||
## 4. `query-tool`(Admin)
|
||||
|
||||
### 前置條件
|
||||
- Admin session 已登入。
|
||||
- 可用測試 lot/equipment/date range。
|
||||
|
||||
### Smoke Cases
|
||||
- [ ] `QTOOL-SMOKE-01` Route reachable: `/query-tool` 可開啟。
|
||||
- [ ] `QTOOL-SMOKE-02` Resolve flow: lot_id/serial/work_order 至少一種解析成功。
|
||||
- [ ] `QTOOL-SMOKE-03` History flow: lot history 可查詢並顯示。
|
||||
- [ ] `QTOOL-SMOKE-04` Adjacent flow: adjacent lots 查詢可回傳。
|
||||
- [ ] `QTOOL-SMOKE-05` Associations: materials/rejects/holds/splits/jobs 查詢可用。
|
||||
- [ ] `QTOOL-SMOKE-06` Equipment period: status_hours/lots/materials/rejects/jobs 至少各成功一次。
|
||||
- [ ] `QTOOL-SMOKE-07` Export CSV: 匯出可下載且欄位合理。
|
||||
- [ ] `QTOOL-SMOKE-08` Validation: 缺參數、非法日期範圍會回傳可讀錯誤。
|
||||
|
||||
## 5. Exit Rule(與 7.4 連動)
|
||||
|
||||
只有在下列條件全成立,才可移除 wrapper:
|
||||
|
||||
- [ ] 四頁 rewrite smoke 全部通過。
|
||||
- [ ] 與 `legacy_wrapper_telemetry_contract.md` 對照,error 率在門檻內。
|
||||
- [ ] 與 `parity_checklist.md` 的 Route/Workflow/API contract 檢查一致。
|
||||
@@ -0,0 +1,21 @@
|
||||
# Legacy Wrapper Exit Criteria (Rewrite-ready)
|
||||
|
||||
A wrapped page is rewrite-ready only when all criteria are met.
|
||||
|
||||
## Functional readiness
|
||||
|
||||
1. Core workflows are documented with at least one deterministic smoke script per workflow.
|
||||
2. Route/query contract is frozen and covered by contract tests.
|
||||
3. Export/upload side effects (if any) are reproducible in test or staging.
|
||||
|
||||
## Technical readiness
|
||||
|
||||
1. Shared UI and composables can cover at least 70% of page scaffolding (filters, cards, table shell, pagination).
|
||||
2. Required API payload key/type contract is stable for two consecutive releases.
|
||||
3. Wrapper telemetry shows no unresolved high-severity navigation failures in the last release cycle.
|
||||
|
||||
## Operational readiness
|
||||
|
||||
1. Rollback path for rewritten page is documented and rehearsed.
|
||||
2. Error budget and success threshold for canary are defined before rewrite starts.
|
||||
3. Product owner confirms parity acceptance checklist for the target page.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Legacy Wrapper Telemetry Contract
|
||||
|
||||
## Status
|
||||
|
||||
- Retired after wrapper decommission.
|
||||
- `POST /api/portal/wrapper-telemetry` has been removed.
|
||||
- Reference only for historical migration traceability.
|
||||
|
||||
## Wrapper scope
|
||||
|
||||
- `/job-query`
|
||||
- `/excel-query`
|
||||
- `/query-tool`
|
||||
- `/tmtt-defect`
|
||||
|
||||
## Client events
|
||||
|
||||
- `wrapper_loaded`: wrapper route rendered in shell.
|
||||
- `launch`: user clicked "進入既有頁面" and navigation handoff started.
|
||||
|
||||
## API endpoint
|
||||
|
||||
- `POST /api/portal/wrapper-telemetry`
|
||||
- Payload:
|
||||
- `event_type: string`
|
||||
- `route: string` (must be one of wrapper scope routes)
|
||||
- `page_name?: string`
|
||||
- `drawer_name?: string`
|
||||
- `duration_ms?: number`
|
||||
- `ts?: string`
|
||||
|
||||
## Validation
|
||||
|
||||
- Reject unknown routes with `400`.
|
||||
- Reject missing `event_type` with `400`.
|
||||
|
||||
## Fallback behavior
|
||||
|
||||
- Wrapper UI always provides direct anchor navigation to the legacy route.
|
||||
- Telemetry failure must not block navigation.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Motion Baseline Guidelines (Vue Transition First)
|
||||
|
||||
## Baseline principles
|
||||
|
||||
1. Motion clarifies state change, not decoration.
|
||||
2. Default to short transitions (180ms - 240ms) with easing.
|
||||
3. Keep animation on container level (route/panel/filter-chip), avoid animating large table row sets.
|
||||
|
||||
## Standard transitions
|
||||
|
||||
- Route change: `route-fade` (`opacity + translateY`) in portal shell.
|
||||
- Drawer navigation: hover/active transition on sidebar links.
|
||||
- Filter apply/remove: `TransitionGroup` chip enter/leave motion.
|
||||
- Data refresh pulse: panel-level pulse when chart/table refresh is running.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Respect `prefers-reduced-motion: reduce`.
|
||||
- All key transitions must have non-animated fallback styles.
|
||||
- Motion must not block interaction or delay data rendering.
|
||||
@@ -0,0 +1,19 @@
|
||||
# GSAP Escalation Rule
|
||||
|
||||
## Default
|
||||
|
||||
Use Vue native transitions and CSS transitions for portal migration work.
|
||||
|
||||
## GSAP allowed only when all conditions are true
|
||||
|
||||
1. Interaction cannot be expressed with native Vue/CSS transitions without major maintainability cost.
|
||||
2. Animation is business-critical (e.g., complex timeline playback or synchronized multi-chart storytelling).
|
||||
3. Reduced-motion fallback is explicitly implemented.
|
||||
4. Performance impact is measured on target hardware and meets baseline thresholds.
|
||||
5. A rollback switch exists to disable advanced animation without breaking functionality.
|
||||
|
||||
## Approval checklist
|
||||
|
||||
- Document the exact scenario and why Vue/CSS is insufficient.
|
||||
- Add test coverage for degraded/non-animated path.
|
||||
- Confirm bundle-size impact is acceptable for the target route.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Shared Pagination Migration Batch 1
|
||||
|
||||
## Scope
|
||||
|
||||
Migrated pages/components:
|
||||
|
||||
- `wip-detail/components/LotTable.vue`
|
||||
- `hold-detail/components/LotTable.vue`
|
||||
- `hold-overview/components/LotTable.vue`
|
||||
- `hold-history/components/DetailTable.vue`
|
||||
- `mid-section-defect/components/DetailTable.vue`
|
||||
|
||||
## Change
|
||||
|
||||
- Replaced direct/inline pagination rendering with shared `PaginationControl`.
|
||||
- Preserved existing page event contracts (`prev-page`, `next-page`).
|
||||
|
||||
## Visual parity checks
|
||||
|
||||
- Pagination visibility still depends on `totalPages > 1`.
|
||||
- Prev/Next button enablement remains bounded by page range.
|
||||
- Page info text format remains unchanged on migrated views.
|
||||
|
||||
## Removed duplicated artifacts
|
||||
|
||||
- Removed local Prev/Next markup and boundary logic from `hold-history/components/DetailTable.vue`.
|
||||
- Consolidated pagination behavior into shared wrapper for this batch.
|
||||
46
docs/migration/portal-no-iframe/parity_checklist.md
Normal file
46
docs/migration/portal-no-iframe/parity_checklist.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Portal No-Iframe Migration Parity Checklist
|
||||
|
||||
This checklist is the execution companion for `portal-no-iframe-navigation` migration.
|
||||
|
||||
## A. Drawer Visibility Parity
|
||||
|
||||
- [ ] Non-admin visible drawers/routes match `baseline_drawer_visibility.json` exactly.
|
||||
- [ ] Admin visible drawers/routes match `baseline_drawer_visibility.json` exactly.
|
||||
- [ ] Empty drawers remain hidden.
|
||||
- [ ] `admin_only` drawer behavior remains unchanged.
|
||||
|
||||
## B. Route and Query Contract Parity
|
||||
|
||||
- [ ] `/wip-overview` preserves `workorder|lotid|package|type|status` URL semantics.
|
||||
- [ ] `/wip-detail` preserves `workcenter|workorder|lotid|package|type|status` URL semantics.
|
||||
- [ ] `/hold-detail` preserves required `reason` semantics and fallback behavior.
|
||||
- [ ] `/resource-history` preserves date/granularity/group/family/resource/flag query semantics.
|
||||
|
||||
## C. Core Workflow Smoke Paths
|
||||
|
||||
- [ ] Legacy rewrite per-page smoke checklist passes (`legacy_rewrite_smoke_checklists.md`).
|
||||
- [ ] `/` open portal and switch via drawer navigation.
|
||||
- [ ] `/wip-overview` apply filters and drill down to `/wip-detail`.
|
||||
- [ ] `/wip-overview` reason drill-down to `/hold-detail`.
|
||||
- [ ] `/resource-history` execute query and export path.
|
||||
- [ ] Legacy rewrite pages (`/job-query`, `/excel-query`, `/query-tool`, `/tmtt-defect`) remain reachable and usable.
|
||||
|
||||
## D. API Payload Contract Parity
|
||||
|
||||
- [ ] `/api/wip/overview/summary` required keys present.
|
||||
- [ ] `/api/wip/overview/matrix` required keys present.
|
||||
- [ ] `/api/wip/hold-detail/summary` required keys present.
|
||||
- [ ] `/api/resource/history/summary` required keys present.
|
||||
- [ ] `/api/resource/history/detail` required keys present.
|
||||
|
||||
## E. Stability and Performance
|
||||
|
||||
- [ ] No unhandled JS runtime errors on critical E2E paths.
|
||||
- [ ] Route switch latency remains within agreed threshold.
|
||||
- [ ] Memory footprint does not regress beyond agreed threshold.
|
||||
|
||||
## F. Cutover Decision
|
||||
|
||||
- [ ] All G1~G7 gates are green.
|
||||
- [ ] Rollback rehearsal result is recent and valid.
|
||||
- [ ] Cutover owner and rollback owner are explicitly assigned.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Performance Baseline Comparison
|
||||
|
||||
Measured via Flask test client (route latency in ms).
|
||||
|
||||
## Key Entry Routes
|
||||
|
||||
| Surface | Avg (ms) | P95 (ms) |
|
||||
| --- | ---: | ---: |
|
||||
| Legacy portal `/` | 1.557 | 0.891 |
|
||||
| SPA shell `/portal-shell` | 0.239 | 0.263 |
|
||||
|
||||
## Shared API Route
|
||||
|
||||
| Route | Legacy Avg (ms) | SPA Avg (ms) | Delta (ms) |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| `/api/portal/navigation` | 0.341 | 0.313 | -0.028 |
|
||||
|
||||
## Notes
|
||||
|
||||
- This baseline is synthetic (test client), used for migration regression gate trend tracking.
|
||||
- Production browser/network RUM should be captured separately during canary rollout.
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"portal_spa_enabled": false,
|
||||
"samples_per_route": 15,
|
||||
"metrics": [
|
||||
{
|
||||
"route": "/",
|
||||
"samples": 15,
|
||||
"avg_ms": 1.557,
|
||||
"p95_ms": 0.891,
|
||||
"min_ms": 0.536,
|
||||
"max_ms": 14.997,
|
||||
"status_codes": [
|
||||
200
|
||||
]
|
||||
},
|
||||
{
|
||||
"route": "/api/portal/navigation",
|
||||
"samples": 15,
|
||||
"avg_ms": 0.341,
|
||||
"p95_ms": 0.396,
|
||||
"min_ms": 0.284,
|
||||
"max_ms": 0.404,
|
||||
"status_codes": [
|
||||
200
|
||||
]
|
||||
},
|
||||
{
|
||||
"route": "/wip-overview",
|
||||
"samples": 15,
|
||||
"avg_ms": 0.683,
|
||||
"p95_ms": 0.925,
|
||||
"min_ms": 0.422,
|
||||
"max_ms": 2.633,
|
||||
"status_codes": [
|
||||
200
|
||||
]
|
||||
},
|
||||
{
|
||||
"route": "/resource",
|
||||
"samples": 15,
|
||||
"avg_ms": 0.413,
|
||||
"p95_ms": 0.506,
|
||||
"min_ms": 0.337,
|
||||
"max_ms": 0.699,
|
||||
"status_codes": [
|
||||
200
|
||||
]
|
||||
},
|
||||
{
|
||||
"route": "/qc-gate",
|
||||
"samples": 15,
|
||||
"avg_ms": 0.422,
|
||||
"p95_ms": 0.453,
|
||||
"min_ms": 0.371,
|
||||
"max_ms": 0.615,
|
||||
"status_codes": [
|
||||
200
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"portal_spa_enabled": true,
|
||||
"samples_per_route": 15,
|
||||
"metrics": [
|
||||
{
|
||||
"route": "/portal-shell",
|
||||
"samples": 15,
|
||||
"avg_ms": 0.239,
|
||||
"p95_ms": 0.263,
|
||||
"min_ms": 0.169,
|
||||
"max_ms": 0.708,
|
||||
"status_codes": [
|
||||
200
|
||||
]
|
||||
},
|
||||
{
|
||||
"route": "/api/portal/navigation",
|
||||
"samples": 15,
|
||||
"avg_ms": 0.313,
|
||||
"p95_ms": 0.412,
|
||||
"min_ms": 0.257,
|
||||
"max_ms": 0.437,
|
||||
"status_codes": [
|
||||
200
|
||||
]
|
||||
},
|
||||
{
|
||||
"route": "/job-query",
|
||||
"samples": 15,
|
||||
"avg_ms": 0.904,
|
||||
"p95_ms": 0.786,
|
||||
"min_ms": 0.33,
|
||||
"max_ms": 7.345,
|
||||
"status_codes": [
|
||||
200
|
||||
]
|
||||
},
|
||||
{
|
||||
"route": "/excel-query",
|
||||
"samples": 15,
|
||||
"avg_ms": 0.47,
|
||||
"p95_ms": 0.448,
|
||||
"min_ms": 0.324,
|
||||
"max_ms": 1.951,
|
||||
"status_codes": [
|
||||
403
|
||||
]
|
||||
},
|
||||
{
|
||||
"route": "/query-tool",
|
||||
"samples": 15,
|
||||
"avg_ms": 0.448,
|
||||
"p95_ms": 0.802,
|
||||
"min_ms": 0.32,
|
||||
"max_ms": 0.849,
|
||||
"status_codes": [
|
||||
403
|
||||
]
|
||||
},
|
||||
{
|
||||
"route": "/tmtt-defect",
|
||||
"samples": 15,
|
||||
"avg_ms": 0.583,
|
||||
"p95_ms": 0.585,
|
||||
"min_ms": 0.323,
|
||||
"max_ms": 3.455,
|
||||
"status_codes": [
|
||||
200
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# Portal No-Iframe Migration Rollback Rehearsal Runbook
|
||||
|
||||
## Objective
|
||||
|
||||
Validate that navigation can be restored to pre-cutover stable behavior within target SLO.
|
||||
|
||||
- Target recovery SLO: <= 15 minutes
|
||||
|
||||
## Trigger Conditions
|
||||
|
||||
Execute rollback when any of the following occur after cutover:
|
||||
|
||||
- P0 route unavailable or broken workflow.
|
||||
- Drawer visibility parity mismatch.
|
||||
- Critical API payload contract mismatch causing page failure.
|
||||
- Severe runtime JS errors on critical user paths.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Feature-flag/env toggle path for shell cutover is in place.
|
||||
- Latest baseline snapshots are available under `docs/migration/portal-no-iframe/`.
|
||||
- On-call owner and rollback owner are assigned.
|
||||
|
||||
## Rehearsal Steps
|
||||
|
||||
1. Enable new navigation mode in staging/canary.
|
||||
2. Execute parity checklist (`parity_checklist.md`) on critical routes.
|
||||
3. Force simulated rollback trigger (toggle off new mode).
|
||||
4. Re-run critical smoke checks:
|
||||
- portal load
|
||||
- drawer visibility
|
||||
- wip overview/detail flow
|
||||
- resource history query path
|
||||
5. Record elapsed recovery time and failures.
|
||||
|
||||
## Verification Criteria
|
||||
|
||||
- Toggle change takes effect without manual code rollback.
|
||||
- Critical routes recover to expected behavior.
|
||||
- Recovery time is within SLO.
|
||||
- No residual hard-failure state remains.
|
||||
|
||||
## Post-Rehearsal Record
|
||||
|
||||
- Date:
|
||||
- Environment:
|
||||
- Operator:
|
||||
- Trigger reason:
|
||||
- Recovery duration:
|
||||
- Issues found:
|
||||
- Follow-up actions:
|
||||
@@ -0,0 +1,28 @@
|
||||
# Rollback Strategy (Shell / Router / Wrapper)
|
||||
|
||||
## Scope
|
||||
|
||||
- Shell entry failures (`/portal-shell`, route guards, navigation API)
|
||||
- Legacy route integration failures (`job-query`, `excel-query`, `query-tool`, `tmtt-defect`)
|
||||
|
||||
## Immediate actions
|
||||
|
||||
1. Flip `PORTAL_SPA_ENABLED=false`.
|
||||
2. Confirm `/` portal route responds and sidebar route links render.
|
||||
3. Verify core routes (`/wip-overview`, `/resource`, `/qc-gate`) return 2xx.
|
||||
4. Verify legacy routes (`/job-query`, `/excel-query`, `/query-tool`, `/tmtt-defect`) return 2xx.
|
||||
|
||||
## Validation checkpoints (<=15 minutes)
|
||||
|
||||
- `GET /api/portal/navigation` returns deterministic drawer/page list.
|
||||
- No spike in 5xx for portal and legacy routes.
|
||||
- Smoke flows for one P0 page and one legacy page pass.
|
||||
|
||||
## Legacy route fallback
|
||||
|
||||
- If a legacy route fails hard, temporarily hide it from drawer config (`page_status.json`) and announce maintenance route.
|
||||
|
||||
## Post-rollback follow-up
|
||||
|
||||
- Capture failed gate(s), timestamp, and impacted routes.
|
||||
- Generate incident summary with fix candidate and rehearsal re-entry criteria.
|
||||
27
docs/migration/portal-no-iframe/rollout_canary_plan.md
Normal file
27
docs/migration/portal-no-iframe/rollout_canary_plan.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Phased Rollout Plan (Canary)
|
||||
|
||||
## Feature switch
|
||||
|
||||
- Primary switch: `PORTAL_SPA_ENABLED`
|
||||
- Canary enabled users/groups are routed to `/portal-shell`; others remain on `/` route-based portal.
|
||||
|
||||
## Phases
|
||||
|
||||
1. Phase A (Internal): dev/admin users only, 1 day.
|
||||
2. Phase B (Canary): 10-20% target users, 2-3 days.
|
||||
3. Phase C (Broad): 50% users if gates are green for 24h.
|
||||
4. Phase D (Full): 100% after cutover gates pass.
|
||||
|
||||
## Success thresholds
|
||||
|
||||
- Route availability (P0): >= 99.9% 2xx/3xx.
|
||||
- Client runtime error rate on critical paths: 0 unhandled exceptions.
|
||||
- Drawer parity drift: 0 mismatches (admin/non-admin route sets).
|
||||
- Wrapper launch success (`launch` telemetry): >= 99%.
|
||||
|
||||
## Error thresholds (rollback trigger)
|
||||
|
||||
- P0 route availability < 99.5% in any 30-minute window.
|
||||
- Any critical workflow smoke failure.
|
||||
- Drawer parity mismatch count > 0 after deployment.
|
||||
- Wrapper telemetry error rate >= 2% sustained 30 minutes.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Shared UI Component Contracts
|
||||
|
||||
## `PaginationControl`
|
||||
|
||||
File: `frontend/src/shared-ui/components/PaginationControl.vue`
|
||||
|
||||
- Props:
|
||||
- `page?: number` (legacy compatibility)
|
||||
- `modelValue?: number`
|
||||
- `totalPages: number`
|
||||
- `infoText?: string`
|
||||
- `visible?: boolean`
|
||||
- Emits:
|
||||
- `update:modelValue(number)`
|
||||
- `change(number)`
|
||||
- `prev(number)`
|
||||
- `next(number)`
|
||||
- Compatibility:
|
||||
- Supports legacy usage (`:page`, `@prev`, `@next`) for migration-safe replacement.
|
||||
|
||||
## `SectionCard`
|
||||
|
||||
File: `frontend/src/shared-ui/components/SectionCard.vue`
|
||||
|
||||
- Slots:
|
||||
- `header`
|
||||
- default body
|
||||
- `footer`
|
||||
- Purpose:
|
||||
- Normalize page section container structure and spacing.
|
||||
|
||||
## `FilterToolbar`
|
||||
|
||||
File: `frontend/src/shared-ui/components/FilterToolbar.vue`
|
||||
|
||||
- Slots:
|
||||
- default filter controls
|
||||
- `actions`
|
||||
- Purpose:
|
||||
- Shared filter layout shell with consistent spacing and action alignment.
|
||||
|
||||
## `StatusBadge`
|
||||
|
||||
File: `frontend/src/shared-ui/components/StatusBadge.vue`
|
||||
|
||||
- Props:
|
||||
- `tone: neutral | success | warning | danger`
|
||||
- `text: string`
|
||||
- Purpose:
|
||||
- Replace repeated local badge/status color snippets.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Shared Composables Contracts
|
||||
|
||||
## `useAutoRefresh`
|
||||
|
||||
File: `frontend/src/shared-composables/useAutoRefresh.js`
|
||||
|
||||
- Current behavior wraps existing `wip-shared` implementation.
|
||||
- Purpose: single import path for all page modules before deeper implementation merge.
|
||||
|
||||
## `useAutocomplete`
|
||||
|
||||
File: `frontend/src/shared-composables/useAutocomplete.js`
|
||||
|
||||
- Current behavior wraps existing `wip-shared` implementation.
|
||||
- Purpose: single import path to normalize field/autocomplete interactions.
|
||||
|
||||
## `usePaginationState`
|
||||
|
||||
File: `frontend/src/shared-composables/usePaginationState.js`
|
||||
|
||||
- State: `page`, `perPage`, `total`, `totalPages`
|
||||
- Derived: `hasPrev`, `hasNext`
|
||||
- Methods: `setFromPayload`, `reset`
|
||||
|
||||
## `useQueryState`
|
||||
|
||||
File: `frontend/src/shared-composables/useQueryState.js`
|
||||
|
||||
- `readQueryState(keys)`
|
||||
- `writeQueryState(nextState)`
|
||||
- Purpose: unify URL query read/write semantics across pages.
|
||||
40
docs/migration/portal-no-iframe/tailwind_design_tokens.md
Normal file
40
docs/migration/portal-no-iframe/tailwind_design_tokens.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Tailwind Design Tokens Mapping
|
||||
|
||||
## Goal
|
||||
|
||||
Map existing portal visual language into a stable token set for phased migration.
|
||||
|
||||
## Color tokens
|
||||
|
||||
- `brand.500` / `brand.600` / `brand.700`: primary brand actions and active navigation states.
|
||||
- `accent.500`: gradient accent endpoint for shell headers.
|
||||
- `surface.app` / `surface.card` / `surface.muted`: app background, card surfaces, muted blocks.
|
||||
- `stroke.soft` / `stroke.panel`: border hierarchy.
|
||||
- `state.success` / `state.warning` / `state.danger` / `state.neutral`: status dots and health states.
|
||||
|
||||
## Typography tokens
|
||||
|
||||
- `fontFamily.sans`: `Noto Sans TC`, `Microsoft JhengHei`, system fallback.
|
||||
|
||||
## Layout tokens
|
||||
|
||||
- `spacing.shell`: outer shell padding.
|
||||
- `spacing.panel`: panel interior spacing.
|
||||
- `spacing.nav`: sidebar item horizontal spacing.
|
||||
- `spacing.block`: vertical rhythm baseline.
|
||||
|
||||
## Radius and elevation tokens
|
||||
|
||||
- `borderRadius.shell`: shell and main card radius.
|
||||
- `borderRadius.card`: smaller control/card radius.
|
||||
- `boxShadow.soft`: light containers (sidebar).
|
||||
- `boxShadow.panel`: content panel container.
|
||||
- `boxShadow.shell`: header gradient card emphasis.
|
||||
|
||||
## Z-index token
|
||||
|
||||
- `zIndex.popup`: status popup / overlay layer.
|
||||
|
||||
## Migration note
|
||||
|
||||
Tokens are intentionally aligned with current portal values to minimize visual drift during iframe decommission.
|
||||
33
docs/migration/portal-no-iframe/tailwind_migration_guide.md
Normal file
33
docs/migration/portal-no-iframe/tailwind_migration_guide.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Tailwind Migration Guide (Portal No-iframe)
|
||||
|
||||
## Purpose
|
||||
|
||||
Move distributed page CSS toward a token-driven Tailwind system without breaking existing portal behavior.
|
||||
|
||||
## Step-by-step
|
||||
|
||||
1. Keep existing route/page behavior unchanged.
|
||||
2. Replace repeated layout wrappers with Tailwind utilities first (`grid`, `flex`, spacing, radius, shadows).
|
||||
3. Replace repeated visual primitives with shared component classes from `@layer components`.
|
||||
4. Move hard-coded colors/spacing to tokens in `tailwind.config.js` and `tailwind.css`.
|
||||
5. Remove obsolete page-local CSS only after visual parity is verified.
|
||||
|
||||
## Recommended migration order
|
||||
|
||||
1. Shell and shared navigation blocks
|
||||
2. Filter bars and KPI card rows
|
||||
3. Shared table containers and pagination controls
|
||||
4. Page-specific edge states and empty/error banners
|
||||
|
||||
## Parity checks per batch
|
||||
|
||||
- Drawer visibility and route links stay unchanged.
|
||||
- Existing URL/query semantics remain compatible.
|
||||
- No new runtime style conflicts in non-admin/admin views.
|
||||
|
||||
## Do / Don’t
|
||||
|
||||
- Do: prefer composable utility classes and shared Vue components.
|
||||
- Do: keep style changes scoped to one route family per batch.
|
||||
- Don’t: introduce new long inline `<style>` blocks in templates.
|
||||
- Don’t: mix unrelated refactors with migration styling tasks.
|
||||
26
docs/migration/portal-no-iframe/tailwind_style_governance.md
Normal file
26
docs/migration/portal-no-iframe/tailwind_style_governance.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Tailwind Style Governance (Migration Phase)
|
||||
|
||||
## Scope
|
||||
|
||||
- Applies to all new frontend work under `frontend/src/**` during iframe removal migration.
|
||||
- Existing page-local CSS can remain temporarily, but new large page-local blocks are disallowed.
|
||||
|
||||
## Rules
|
||||
|
||||
1. New shared UI styles must be authored in Tailwind layers (`base`, `components`, `utilities`) under `frontend/src/styles/tailwind.css`.
|
||||
2. Reusable patterns (cards, filter bars, badge groups, table shells) must use component classes or Vue components, not copy-pasted CSS.
|
||||
3. Page-specific CSS additions over 40 lines require an explicit migration note in the PR and an issue to move them into shared layers.
|
||||
4. Token values must come from `tailwind.config.js` or CSS variables in `tailwind.css`; hard-coded new color scales are disallowed.
|
||||
5. Motion/accessibility styles must support reduced-motion fallback and avoid forced animation on critical data refresh paths.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- New files import `frontend/src/styles/tailwind.css` through the entry module.
|
||||
- No new iframe-targeting selectors are introduced.
|
||||
- Shared classes/components are reused before adding page-local CSS.
|
||||
- Token naming remains stable (`brand`, `surface`, `stroke`, `state`, spacing/radius/shadow/z-index).
|
||||
|
||||
## Exceptions
|
||||
|
||||
- Bugfix hotfixes may temporarily bypass these rules only if release risk is high.
|
||||
- Every exception must include an expiry task in `openspec/changes/portal-no-iframe-navigation/tasks.md`.
|
||||
44
docs/migration/portal-no-iframe/tmtt_rewrite_exemplar.md
Normal file
44
docs/migration/portal-no-iframe/tmtt_rewrite_exemplar.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# `tmtt-defect` Rewrite Exemplar
|
||||
|
||||
## Scope
|
||||
|
||||
- Route: `/tmtt-defect`
|
||||
- Goal: establish the first canonical legacy rewrite pattern with:
|
||||
- Vue SFC composition
|
||||
- shared UI layer reuse
|
||||
- Tailwind token layer coexistence
|
||||
- no iframe / no wrapper dependency
|
||||
|
||||
## Implemented Structure
|
||||
|
||||
- Entry: `frontend/src/tmtt-defect/main.js`
|
||||
- Page container: `frontend/src/tmtt-defect/App.vue`
|
||||
- Data state/composable: `frontend/src/tmtt-defect/composables/useTmttDefectData.js`
|
||||
- Reusable page components:
|
||||
- `frontend/src/tmtt-defect/components/TmttKpiCards.vue`
|
||||
- `frontend/src/tmtt-defect/components/TmttChartCard.vue`
|
||||
- `frontend/src/tmtt-defect/components/TmttDetailTable.vue`
|
||||
- Shared UI usage:
|
||||
- `frontend/src/shared-ui/components/FilterToolbar.vue`
|
||||
- `frontend/src/shared-ui/components/SectionCard.vue`
|
||||
- `frontend/src/shared-ui/components/StatusBadge.vue`
|
||||
- Backend template mount shell: `src/mes_dashboard/templates/tmtt_defect.html`
|
||||
|
||||
## Behavioral Parity
|
||||
|
||||
The rewrite keeps current route and API contracts:
|
||||
|
||||
- Query API: `GET /api/tmtt-defect/analysis`
|
||||
- Export API: `GET /api/tmtt-defect/export`
|
||||
- Sort/filter/detail behavior preserved on result table
|
||||
|
||||
Smoke coverage references:
|
||||
|
||||
- `TMTT-SMOKE-01` ~ `TMTT-SMOKE-06` in `legacy_rewrite_smoke_checklists.md`
|
||||
|
||||
## Verification Snapshot
|
||||
|
||||
- `npm --prefix frontend run build` passed
|
||||
- `pytest -q tests/test_template_integration.py tests/test_portal_shell_routes.py tests/test_cutover_gates.py tests/test_app_factory.py` passed
|
||||
|
||||
This page is the baseline implementation that remaining legacy rewrites follow.
|
||||
41
docs/migration/portal-no-iframe/ui_pattern_inventory.md
Normal file
41
docs/migration/portal-no-iframe/ui_pattern_inventory.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# UI Pattern Inventory (WIP / Resource / Hold / QC)
|
||||
|
||||
## Duplicated patterns observed
|
||||
|
||||
1. Filter bars:
|
||||
- `hold-overview/components/FilterBar.vue`
|
||||
- `hold-history/components/FilterBar.vue`
|
||||
- `resource-status/components/FilterBar.vue`
|
||||
- `resource-history/components/FilterBar.vue`
|
||||
- `mid-section-defect/components/FilterBar.vue`
|
||||
2. KPI/Summary cards:
|
||||
- `wip-overview/components/SummaryCards.vue`
|
||||
- `wip-detail/components/SummaryCards.vue`
|
||||
- `hold-detail/components/SummaryCards.vue`
|
||||
- `hold-history/components/SummaryCards.vue`
|
||||
- `resource-status/components/SummaryCards.vue`
|
||||
- `resource-history/components/KpiCards.vue`
|
||||
- `mid-section-defect/components/KpiCards.vue`
|
||||
3. Table + pagination shells:
|
||||
- `wip-detail/components/LotTable.vue`
|
||||
- `hold-detail/components/LotTable.vue`
|
||||
- `hold-overview/components/LotTable.vue`
|
||||
- `hold-history/components/DetailTable.vue`
|
||||
- `mid-section-defect/components/DetailTable.vue`
|
||||
- `qc-gate/components/LotTable.vue`
|
||||
4. Multi-select and query controls:
|
||||
- `resource-shared/components/MultiSelect.vue`
|
||||
- `mid-section-defect/components/MultiSelect.vue`
|
||||
5. Repeated status/badge presentation logic:
|
||||
- WIP/Hold status class mapping and local badge styles in multiple tables/cards.
|
||||
|
||||
## Consolidation targets
|
||||
|
||||
- Shared UI layer (`frontend/src/shared-ui/components`)
|
||||
- Shared composables layer (`frontend/src/shared-composables`)
|
||||
- Tailwind tokenized styles (`frontend/src/styles/tailwind.css`)
|
||||
|
||||
## First migration batch completed
|
||||
|
||||
- Unified pagination rendering for WIP/Hold/Mid-section detail tables through `PaginationControl` wrapper.
|
||||
- Auto-refresh and autocomplete imports migrated to `shared-composables` entry points.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Wrapper Decommission Report
|
||||
|
||||
## Decision
|
||||
|
||||
Legacy shell wrapper mode has been decommissioned after rewrite milestone validation.
|
||||
|
||||
## Changes Applied
|
||||
|
||||
- Removed shell wrapper route branch:
|
||||
- `frontend/src/portal-shell/router.js`
|
||||
- `frontend/src/portal-shell/App.vue`
|
||||
- Removed wrapper-specific frontend artifacts:
|
||||
- deleted `frontend/src/portal-shell/constants.js`
|
||||
- deleted `frontend/src/portal-shell/views/LegacyWrapperView.vue`
|
||||
- Removed backend wrapper telemetry endpoint:
|
||||
- deleted `/api/portal/wrapper-telemetry` in `src/mes_dashboard/app.py`
|
||||
|
||||
## Operational Outcome
|
||||
|
||||
- Portal shell navigation now uses direct page-bridge behavior only.
|
||||
- Legacy page access remains available via direct routes.
|
||||
- Wrapper telemetry contract is retired.
|
||||
|
||||
## Validation
|
||||
|
||||
- Route and template integration tests updated and passing.
|
||||
- Cutover gate tests remain green after wrapper removal.
|
||||
1091
frontend/package-lock.json
generated
1091
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,16 +5,20 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build && cp ../src/mes_dashboard/static/dist/src/tables/index.html ../src/mes_dashboard/static/dist/tables.html && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html && cp ../src/mes_dashboard/static/dist/src/wip-overview/index.html ../src/mes_dashboard/static/dist/wip-overview.html && cp ../src/mes_dashboard/static/dist/src/wip-detail/index.html ../src/mes_dashboard/static/dist/wip-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-detail/index.html ../src/mes_dashboard/static/dist/hold-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-overview/index.html ../src/mes_dashboard/static/dist/hold-overview.html && cp ../src/mes_dashboard/static/dist/src/hold-history/index.html ../src/mes_dashboard/static/dist/hold-history.html && cp ../src/mes_dashboard/static/dist/src/resource-status/index.html ../src/mes_dashboard/static/dist/resource-status.html && cp ../src/mes_dashboard/static/dist/src/resource-history/index.html ../src/mes_dashboard/static/dist/resource-history.html && cp ../src/mes_dashboard/static/dist/src/mid-section-defect/index.html ../src/mes_dashboard/static/dist/mid-section-defect.html",
|
||||
"build": "vite build && cp ../src/mes_dashboard/static/dist/src/portal-shell/index.html ../src/mes_dashboard/static/dist/portal-shell.html && cp ../src/mes_dashboard/static/dist/src/tables/index.html ../src/mes_dashboard/static/dist/tables.html && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html && cp ../src/mes_dashboard/static/dist/src/wip-overview/index.html ../src/mes_dashboard/static/dist/wip-overview.html && cp ../src/mes_dashboard/static/dist/src/wip-detail/index.html ../src/mes_dashboard/static/dist/wip-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-detail/index.html ../src/mes_dashboard/static/dist/hold-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-overview/index.html ../src/mes_dashboard/static/dist/hold-overview.html && cp ../src/mes_dashboard/static/dist/src/hold-history/index.html ../src/mes_dashboard/static/dist/hold-history.html && cp ../src/mes_dashboard/static/dist/src/resource-status/index.html ../src/mes_dashboard/static/dist/resource-status.html && cp ../src/mes_dashboard/static/dist/src/resource-history/index.html ../src/mes_dashboard/static/dist/resource-history.html && cp ../src/mes_dashboard/static/dist/src/mid-section-defect/index.html ../src/mes_dashboard/static/dist/mid-section-defect.html",
|
||||
"test": "node --test tests/*.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.24",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^6.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"echarts": "^6.0.0",
|
||||
"vue": "^3.5.27",
|
||||
"vue-echarts": "^8.0.1"
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-router": "^4.6.4"
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet } from '../core/api.js';
|
||||
import { NON_QUALITY_HOLD_REASON_SET } from '../wip-shared/constants.js';
|
||||
import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js';
|
||||
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
|
||||
|
||||
import AgeDistribution from './components/AgeDistribution.vue';
|
||||
import DistributionTable from './components/DistributionTable.vue';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import Pagination from '../../wip-shared/components/Pagination.vue';
|
||||
import Pagination from '../../shared-ui/components/PaginationControl.vue';
|
||||
|
||||
const props = defineProps({
|
||||
lots: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, reactive } from 'vue';
|
||||
import Pagination from '../../shared-ui/components/PaginationControl.vue';
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
@@ -22,9 +23,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['prev-page', 'next-page']);
|
||||
|
||||
const canPrev = computed(() => Number(props.pagination?.page || 1) > 1);
|
||||
const canNext = computed(() => Number(props.pagination?.page || 1) < Number(props.pagination?.totalPages || 1));
|
||||
|
||||
const pageSummary = computed(() => {
|
||||
const page = Number(props.pagination?.page || 1);
|
||||
const perPage = Number(props.pagination?.perPage || 50);
|
||||
@@ -39,6 +37,12 @@ const pageSummary = computed(() => {
|
||||
return `顯示 ${start} - ${end} / ${total.toLocaleString('zh-TW')}`;
|
||||
});
|
||||
|
||||
const pageInfo = computed(() => {
|
||||
const page = Number(props.pagination?.page || 1);
|
||||
const totalPages = Number(props.pagination?.totalPages || 1);
|
||||
return `Page ${page} / ${totalPages}`;
|
||||
});
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '-';
|
||||
@@ -131,11 +135,14 @@ function hideTip() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<button type="button" :disabled="!canPrev" @click="emit('prev-page')">Prev</button>
|
||||
<span class="page-info">Page {{ pagination.page || 1 }} / {{ pagination.totalPages || 1 }}</span>
|
||||
<button type="button" :disabled="!canNext" @click="emit('next-page')">Next</button>
|
||||
</div>
|
||||
<Pagination
|
||||
:visible="Number(pagination.totalPages || 1) > 1"
|
||||
:page="Number(pagination.page || 1)"
|
||||
:total-pages="Number(pagination.totalPages || 1)"
|
||||
:info-text="pageInfo"
|
||||
@prev="emit('prev-page')"
|
||||
@next="emit('next-page')"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Teleport to="body">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet } from '../core/api.js';
|
||||
import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js';
|
||||
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
|
||||
|
||||
import SummaryCards from '../hold-detail/components/SummaryCards.vue';
|
||||
import FilterBar from './components/FilterBar.vue';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import Pagination from '../../wip-shared/components/Pagination.vue';
|
||||
import Pagination from '../../shared-ui/components/PaginationControl.vue';
|
||||
|
||||
const props = defineProps({
|
||||
lots: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet, ensureMesApiAvailable } from '../core/api.js';
|
||||
import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js';
|
||||
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
|
||||
|
||||
import FilterBar from './components/FilterBar.vue';
|
||||
import KpiCards from './components/KpiCards.vue';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import Pagination from '../../wip-shared/components/Pagination.vue';
|
||||
import Pagination from '../../shared-ui/components/PaginationControl.vue';
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
|
||||
125
frontend/src/portal-shell/App.vue
Normal file
125
frontend/src/portal-shell/App.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import HealthStatus from './components/HealthStatus.vue';
|
||||
import { syncNavigationRoutes } from './router.js';
|
||||
|
||||
const route = useRoute();
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref('');
|
||||
const drawers = ref([]);
|
||||
const isAdmin = ref(false);
|
||||
const adminUser = ref(null);
|
||||
|
||||
function toShellPath(targetRoute) {
|
||||
const normalized = String(targetRoute || '').trim();
|
||||
if (!normalized || normalized === '/') {
|
||||
return '/';
|
||||
}
|
||||
return `/${normalized.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
const breadcrumb = computed(() => {
|
||||
const title = route.meta?.title || '首頁';
|
||||
const drawerName = route.meta?.drawerName || '';
|
||||
return {
|
||||
drawerName,
|
||||
title,
|
||||
};
|
||||
});
|
||||
|
||||
const adminDisplayName = computed(() => {
|
||||
if (!adminUser.value) return '';
|
||||
return adminUser.value.displayName || adminUser.value.username || '';
|
||||
});
|
||||
|
||||
const adminLoginHref = computed(() => `/admin/login?next=${encodeURIComponent('/portal-shell')}`);
|
||||
|
||||
async function loadNavigation() {
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/portal/navigation', { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Navigation API error: ${response.status}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
drawers.value = Array.isArray(payload.drawers) ? payload.drawers : [];
|
||||
isAdmin.value = Boolean(payload.is_admin);
|
||||
adminUser.value = payload.admin_user || null;
|
||||
syncNavigationRoutes(drawers.value);
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '無法載入導覽資料';
|
||||
drawers.value = [];
|
||||
isAdmin.value = false;
|
||||
adminUser.value = null;
|
||||
syncNavigationRoutes([]);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadNavigation();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<header class="shell-header">
|
||||
<div>
|
||||
<h1>MES 報表入口 (SPA Shell)</h1>
|
||||
<p>No-iframe 路由導覽(遷移完成)</p>
|
||||
</div>
|
||||
<div class="shell-header-right">
|
||||
<HealthStatus />
|
||||
<div class="admin-entry">
|
||||
<template v-if="isAdmin">
|
||||
<a class="admin-link" href="/admin/pages">管理後台</a>
|
||||
<span v-if="adminDisplayName" class="admin-name">{{ adminDisplayName }}</span>
|
||||
<a class="admin-link" href="/admin/logout">登出</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a class="admin-link" :href="adminLoginHref">管理員登入</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="shell-main">
|
||||
<aside class="sidebar">
|
||||
<div v-if="loading" class="muted">載入導覽中...</div>
|
||||
<div v-else-if="errorMessage" class="error">{{ errorMessage }}</div>
|
||||
<template v-else>
|
||||
<section v-for="drawer in drawers" :key="drawer.id" class="drawer">
|
||||
<h2 class="drawer-title">{{ drawer.name }}</h2>
|
||||
<RouterLink
|
||||
v-for="page in drawer.pages"
|
||||
:key="page.route"
|
||||
class="drawer-link"
|
||||
active-class="active"
|
||||
:to="toShellPath(page.route)"
|
||||
>
|
||||
<span>{{ page.name }}</span>
|
||||
</RouterLink>
|
||||
</section>
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<section class="content">
|
||||
<div class="breadcrumb">
|
||||
<span v-if="breadcrumb.drawerName">{{ breadcrumb.drawerName }}</span>
|
||||
<span v-if="breadcrumb.drawerName">/</span>
|
||||
<span>{{ breadcrumb.title }}</span>
|
||||
</div>
|
||||
<RouterView v-slot="{ Component, route: currentRoute }">
|
||||
<Transition name="route-fade" mode="out-in">
|
||||
<component :is="Component" :key="currentRoute.fullPath" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
218
frontend/src/portal-shell/components/HealthStatus.vue
Normal file
218
frontend/src/portal-shell/components/HealthStatus.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
const status = ref('loading');
|
||||
const label = ref('檢查中...');
|
||||
const popupOpen = ref(false);
|
||||
|
||||
const detail = ref({
|
||||
database: '--',
|
||||
redis: '--',
|
||||
cacheEnabled: '--',
|
||||
cacheUpdatedAt: '--',
|
||||
resourceCacheEnabled: '--',
|
||||
resourceCacheCount: '--',
|
||||
routeCacheMode: '--',
|
||||
routeCacheHitRate: '--',
|
||||
routeCacheDegraded: '--',
|
||||
frontendShell: '--',
|
||||
});
|
||||
|
||||
const warnings = ref([]);
|
||||
const frontendErrors = ref([]);
|
||||
|
||||
let timer = null;
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (status.value === 'healthy') return 'healthy';
|
||||
if (status.value === 'degraded') return 'degraded';
|
||||
if (status.value === 'loading') return 'loading';
|
||||
return 'unhealthy';
|
||||
});
|
||||
|
||||
function toStatusText(raw) {
|
||||
if (raw === 'ok') return '正常';
|
||||
if (raw === 'disabled') return '未啟用';
|
||||
return '異常';
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '--';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (Number.isNaN(date.getTime())) return dateStr;
|
||||
return date.toLocaleString('zh-TW', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHitRate(value) {
|
||||
if (value == null || value === '') return '--';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function togglePopup() {
|
||||
popupOpen.value = !popupOpen.value;
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
popupOpen.value = false;
|
||||
}
|
||||
|
||||
function onDocumentClick(event) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (target.closest('#shellHealthWrap')) {
|
||||
return;
|
||||
}
|
||||
closePopup();
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const [healthResp, shellResp] = await Promise.all([
|
||||
fetch('/health', { cache: 'no-store' }),
|
||||
fetch('/health/frontend-shell', { cache: 'no-store' }),
|
||||
]);
|
||||
|
||||
if (!healthResp.ok) {
|
||||
throw new Error(`Health API ${healthResp.status}`);
|
||||
}
|
||||
|
||||
const healthData = await healthResp.json();
|
||||
const shellData = shellResp.ok ? await shellResp.json() : null;
|
||||
|
||||
status.value = healthData.status || 'unhealthy';
|
||||
if (status.value === 'healthy') {
|
||||
label.value = '連線正常';
|
||||
} else if (status.value === 'degraded') {
|
||||
label.value = '部分降級';
|
||||
} else {
|
||||
label.value = '連線異常';
|
||||
}
|
||||
|
||||
detail.value = {
|
||||
database: toStatusText(healthData.services?.database || 'error'),
|
||||
redis: toStatusText(healthData.services?.redis || 'disabled'),
|
||||
cacheEnabled: healthData.cache?.enabled ? '已啟用' : '未啟用',
|
||||
cacheUpdatedAt: formatDateTime(healthData.cache?.updated_at),
|
||||
resourceCacheEnabled: healthData.resource_cache?.enabled
|
||||
? (healthData.resource_cache?.loaded ? '已載入' : '未載入')
|
||||
: '未啟用',
|
||||
resourceCacheCount: healthData.resource_cache?.count != null
|
||||
? `${healthData.resource_cache.count} 筆`
|
||||
: '--',
|
||||
routeCacheMode: healthData.route_cache?.mode || '--',
|
||||
routeCacheHitRate: `${normalizeHitRate(healthData.route_cache?.l1_hit_rate)} / ${normalizeHitRate(healthData.route_cache?.l2_hit_rate)}`,
|
||||
routeCacheDegraded: healthData.route_cache?.degraded ? '是' : '否',
|
||||
frontendShell: shellData?.status === 'healthy' ? '正常' : '異常',
|
||||
};
|
||||
|
||||
warnings.value = Array.isArray(healthData.warnings) ? healthData.warnings : [];
|
||||
frontendErrors.value = Array.isArray(shellData?.errors) ? shellData.errors : [];
|
||||
} catch {
|
||||
status.value = 'unhealthy';
|
||||
label.value = '無法連線';
|
||||
detail.value = {
|
||||
database: '無法確認',
|
||||
redis: '無法確認',
|
||||
cacheEnabled: '無法確認',
|
||||
cacheUpdatedAt: '--',
|
||||
resourceCacheEnabled: '無法確認',
|
||||
resourceCacheCount: '--',
|
||||
routeCacheMode: '--',
|
||||
routeCacheHitRate: '--',
|
||||
routeCacheDegraded: '--',
|
||||
frontendShell: '無法確認',
|
||||
};
|
||||
warnings.value = [];
|
||||
frontendErrors.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void checkHealth();
|
||||
timer = window.setInterval(() => {
|
||||
void checkHealth();
|
||||
}, 30000);
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
document.removeEventListener('click', onDocumentClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="shellHealthWrap" class="health-wrap">
|
||||
<button type="button" class="health-trigger" :aria-expanded="popupOpen ? 'true' : 'false'" @click="togglePopup">
|
||||
<span class="dot" :class="statusClass"></span>
|
||||
<span class="label">{{ label }}</span>
|
||||
<span class="meta-toggle">詳情</span>
|
||||
</button>
|
||||
|
||||
<div v-if="popupOpen" class="health-popup">
|
||||
<h4>系統連線狀態</h4>
|
||||
<div class="health-item">
|
||||
<span class="health-item-label">資料庫 (Oracle)</span>
|
||||
<span class="health-item-value">{{ detail.database }}</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-item-label">快取 (Redis)</span>
|
||||
<span class="health-item-value">{{ detail.redis }}</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-item-label">WIP 快取</span>
|
||||
<span class="health-item-value">{{ detail.cacheEnabled }}</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-item-label">WIP 最後同步</span>
|
||||
<span class="health-item-value">{{ detail.cacheUpdatedAt }}</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-item-label">設備主檔快取</span>
|
||||
<span class="health-item-value">{{ detail.resourceCacheEnabled }} / {{ detail.resourceCacheCount }}</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-item-label">路由快取模式</span>
|
||||
<span class="health-item-value">{{ detail.routeCacheMode }}</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-item-label">路由快取命中 (L1/L2)</span>
|
||||
<span class="health-item-value">{{ detail.routeCacheHitRate }}</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-item-label">路由快取降級</span>
|
||||
<span class="health-item-value">{{ detail.routeCacheDegraded }}</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-item-label">Frontend Shell 資產</span>
|
||||
<span class="health-item-value">{{ detail.frontendShell }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="warnings.length" class="health-section">
|
||||
<h5>Warnings</h5>
|
||||
<ul>
|
||||
<li v-for="message in warnings" :key="message">{{ message }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="frontendErrors.length" class="health-section health-section-error">
|
||||
<h5>Frontend Shell Errors</h5>
|
||||
<ul>
|
||||
<li v-for="message in frontendErrors" :key="message">{{ message }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
12
frontend/src/portal-shell/index.html
Normal file
12
frontend/src/portal-shell/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>MES Portal Shell</title>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
10
frontend/src/portal-shell/main.js
Normal file
10
frontend/src/portal-shell/main.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import App from './App.vue';
|
||||
import { router } from './router.js';
|
||||
import '../styles/tailwind.css';
|
||||
import './style.css';
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
85
frontend/src/portal-shell/router.js
Normal file
85
frontend/src/portal-shell/router.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
import PageBridgeView from './views/PageBridgeView.vue';
|
||||
import ShellHomeView from './views/ShellHomeView.vue';
|
||||
|
||||
let allowedRoutePaths = new Set(['/']);
|
||||
let dynamicRouteNames = [];
|
||||
|
||||
function toShellPath(route) {
|
||||
const normalized = String(route || '').trim();
|
||||
if (!normalized || normalized === '/') {
|
||||
return '/';
|
||||
}
|
||||
return `/${normalized.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory('/portal-shell'),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'shell-home',
|
||||
component: ShellHomeView,
|
||||
meta: { title: 'MES Portal Shell' }
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'shell-fallback',
|
||||
redirect: '/'
|
||||
}
|
||||
],
|
||||
scrollBehavior() {
|
||||
return { top: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
export function syncNavigationRoutes(drawers) {
|
||||
dynamicRouteNames.forEach((name) => {
|
||||
if (router.hasRoute(name)) {
|
||||
router.removeRoute(name);
|
||||
}
|
||||
});
|
||||
dynamicRouteNames = [];
|
||||
|
||||
const nextAllowed = new Set(['/']);
|
||||
let index = 0;
|
||||
|
||||
(drawers || []).forEach((drawer) => {
|
||||
(drawer.pages || []).forEach((page) => {
|
||||
const shellPath = toShellPath(page.route);
|
||||
if (shellPath === '/') {
|
||||
return;
|
||||
}
|
||||
|
||||
const routeName = `shell-page-${index++}`;
|
||||
router.addRoute({
|
||||
path: shellPath,
|
||||
name: routeName,
|
||||
component: PageBridgeView,
|
||||
props: {
|
||||
targetRoute: page.route,
|
||||
pageName: page.name || page.route,
|
||||
drawerName: drawer.name || drawer.id || ''
|
||||
},
|
||||
meta: {
|
||||
title: page.name || page.route,
|
||||
drawerName: drawer.name || drawer.id || '',
|
||||
targetRoute: page.route,
|
||||
}
|
||||
});
|
||||
|
||||
dynamicRouteNames.push(routeName);
|
||||
nextAllowed.add(shellPath);
|
||||
});
|
||||
});
|
||||
|
||||
allowedRoutePaths = nextAllowed;
|
||||
}
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.path === '/' || allowedRoutePaths.has(to.path)) {
|
||||
return true;
|
||||
}
|
||||
return { path: '/' };
|
||||
});
|
||||
364
frontend/src/portal-shell/style.css
Normal file
364
frontend/src/portal-shell/style.css
Normal file
@@ -0,0 +1,364 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Microsoft JhengHei", "Noto Sans TC", sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.shell-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.shell-header h1 {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.shell-header p {
|
||||
margin: 6px 0 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.shell-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-entry {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-link {
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.32);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.admin-link:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.admin-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shell-main {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
height: fit-content;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.drawer + .drawer {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drawer-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
transition: background 0.18s ease, color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.drawer-link:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.drawer-link.active {
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-bottom: 12px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.panel p {
|
||||
margin: 0 0 8px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.health-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
.dot.healthy {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.6);
|
||||
}
|
||||
|
||||
.dot.degraded {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 6px rgba(245, 158, 11, 0.6);
|
||||
}
|
||||
|
||||
.dot.unhealthy {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.6);
|
||||
}
|
||||
|
||||
.dot.loading {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.health-trigger {
|
||||
border: 1px solid rgba(255, 255, 255, 0.32);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.health-trigger:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 12px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.meta-toggle {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.35);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.health-popup {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 32px);
|
||||
color: #1f2937;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.24);
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 14px;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.health-popup h4 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
.health-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.health-item + .health-item {
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.health-item-label {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.health-item-value {
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.health-section {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.health-section h5 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.health-section ul {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.health-section-error h5 {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.health-section-error ul {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.route-fade-enter-active,
|
||||
.route-fade-leave-active {
|
||||
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||
}
|
||||
|
||||
.route-fade-enter-from,
|
||||
.route-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.drawer-link,
|
||||
.route-fade-enter-active,
|
||||
.route-fade-leave-active {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.shell-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.shell-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.shell-header-right {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.health-trigger {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
36
frontend/src/portal-shell/views/PageBridgeView.vue
Normal file
36
frontend/src/portal-shell/views/PageBridgeView.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
targetRoute: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
pageName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
drawerName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const launchHref = computed(() => props.targetRoute || '/');
|
||||
|
||||
function launch() {
|
||||
window.location.href = launchHref.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<h2>{{ pageName }}</h2>
|
||||
<p v-if="drawerName">分類:{{ drawerName }}</p>
|
||||
<p>此頁已建立 router 導航掛載點。下一階段將逐步把內容整合至 shell route view。</p>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-primary" @click="launch">開啟既有頁面</button>
|
||||
<a class="btn-link" :href="launchHref">直接前往 {{ launchHref }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
6
frontend/src/portal-shell/views/ShellHomeView.vue
Normal file
6
frontend/src/portal-shell/views/ShellHomeView.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div class="panel">
|
||||
<h2>Portal Shell Ready</h2>
|
||||
<p>請由左側抽屜選擇頁面。此殼層提供 router 導覽、抽屜可見性與健康狀態治理。</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,8 @@
|
||||
import './portal.css';
|
||||
|
||||
(function initPortal() {
|
||||
const sidebarItems = document.querySelectorAll('.sidebar-item[data-target]');
|
||||
const frames = document.querySelectorAll('iframe');
|
||||
const sidebarItems = document.querySelectorAll('.sidebar-item[data-route]');
|
||||
const routeStatus = document.getElementById('routeStatus');
|
||||
const healthDot = document.getElementById('healthDot');
|
||||
const healthLabel = document.getElementById('healthLabel');
|
||||
const healthPopup = document.getElementById('healthPopup');
|
||||
@@ -18,49 +18,6 @@ import './portal.css';
|
||||
const routeCacheHitRate = document.getElementById('routeCacheHitRate');
|
||||
const routeCacheDegraded = document.getElementById('routeCacheDegraded');
|
||||
|
||||
function setFrameHeight() {
|
||||
const header = document.querySelector('.header');
|
||||
if (!header) return;
|
||||
const height = Math.max(600, window.innerHeight - header.offsetHeight - 52);
|
||||
frames.forEach((frame) => {
|
||||
frame.style.height = `${height}px`;
|
||||
});
|
||||
}
|
||||
|
||||
function activateTab(targetId, toolSrc) {
|
||||
sidebarItems.forEach((item) => item.classList.remove('active'));
|
||||
|
||||
// Unload inactive iframes to free memory and stop their timers
|
||||
frames.forEach((frame) => {
|
||||
if (frame.classList.contains('active') && frame.id !== targetId) {
|
||||
if (frame.src) {
|
||||
frame.dataset.src = frame.src;
|
||||
}
|
||||
frame.removeAttribute('src');
|
||||
}
|
||||
frame.classList.remove('active');
|
||||
});
|
||||
|
||||
const activeItems = document.querySelectorAll(`.sidebar-item[data-target="${targetId}"]`);
|
||||
activeItems.forEach((item) => {
|
||||
if (!toolSrc || item.dataset.toolSrc === toolSrc) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
const targetFrame = document.getElementById(targetId);
|
||||
if (targetFrame) {
|
||||
targetFrame.classList.add('active');
|
||||
if (toolSrc) {
|
||||
if (targetFrame.src !== toolSrc && !targetFrame.src.endsWith(toolSrc)) {
|
||||
targetFrame.src = toolSrc;
|
||||
}
|
||||
} else if (targetFrame.dataset.src && !targetFrame.src) {
|
||||
targetFrame.src = targetFrame.dataset.src;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleHealthPopup() {
|
||||
if (!healthPopup) return;
|
||||
healthPopup.classList.toggle('show');
|
||||
@@ -93,6 +50,18 @@ import './portal.css';
|
||||
}
|
||||
}
|
||||
|
||||
function markActiveSidebar() {
|
||||
const currentPath = window.location.pathname;
|
||||
sidebarItems.forEach((item) => {
|
||||
const route = item.dataset.route;
|
||||
if (route && route === currentPath) {
|
||||
item.classList.add('active');
|
||||
} else {
|
||||
item.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
if (!healthDot || !healthLabel) return;
|
||||
try {
|
||||
@@ -143,9 +112,7 @@ import './portal.css';
|
||||
}
|
||||
|
||||
const routeCache = data.route_cache || {};
|
||||
if (routeCacheMode) {
|
||||
routeCacheMode.textContent = routeCache.mode || '--';
|
||||
}
|
||||
if (routeCacheMode) routeCacheMode.textContent = routeCache.mode || '--';
|
||||
if (routeCacheHitRate) {
|
||||
const l1 = routeCache.l1_hit_rate ?? '--';
|
||||
const l2 = routeCache.l2_hit_rate ?? '--';
|
||||
@@ -173,15 +140,15 @@ import './portal.css';
|
||||
|
||||
sidebarItems.forEach((item) => {
|
||||
item.addEventListener('click', () => {
|
||||
activateTab(item.dataset.target, item.dataset.toolSrc || null);
|
||||
if (!routeStatus) return;
|
||||
routeStatus.classList.add('loading');
|
||||
routeStatus.textContent = `正在前往 ${item.dataset.pageName || item.dataset.route || '頁面'}...`;
|
||||
});
|
||||
});
|
||||
|
||||
if (sidebarItems.length > 0) {
|
||||
activateTab(sidebarItems[0].dataset.target, sidebarItems[0].dataset.toolSrc || null);
|
||||
}
|
||||
|
||||
markActiveSidebar();
|
||||
window.toggleHealthPopup = toggleHealthPopup;
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#healthStatus') && !e.target.closest('#healthPopup') && healthPopup) {
|
||||
healthPopup.classList.remove('show');
|
||||
@@ -190,6 +157,4 @@ import './portal.css';
|
||||
|
||||
checkHealth();
|
||||
setInterval(checkHealth, 30000);
|
||||
window.addEventListener('resize', setFrameHeight);
|
||||
setFrameHeight();
|
||||
})();
|
||||
|
||||
@@ -30,8 +30,6 @@
|
||||
.sidebar-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
@@ -53,3 +51,19 @@
|
||||
font-weight: 600;
|
||||
border-right: 3px solid #667eea;
|
||||
}
|
||||
|
||||
.route-status {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
padding: 10px 12px;
|
||||
margin-top: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.route-status.loading {
|
||||
border-color: #c7d2fe;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ function handleManualRefresh() {
|
||||
<div v-if="errorMessage" class="error-banner">{{ errorMessage }}</div>
|
||||
|
||||
<main class="qc-gate-content">
|
||||
<section class="panel chart-panel">
|
||||
<section class="panel chart-panel" :class="{ 'is-refreshing': refreshing }">
|
||||
<div class="panel-header">
|
||||
<h2>站點等待時間分布</h2>
|
||||
<span class="panel-hint">點擊圖表區段可篩選下方 LOT 清單</span>
|
||||
@@ -146,7 +146,7 @@ function handleManualRefresh() {
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="panel table-panel">
|
||||
<section class="panel table-panel" :class="{ 'is-refreshing': refreshing }">
|
||||
<div class="panel-header">
|
||||
<h2>LOT 明細</h2>
|
||||
<button
|
||||
|
||||
@@ -124,6 +124,12 @@ body {
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.panel.is-refreshing {
|
||||
border-color: #c7d2fe;
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@@ -160,6 +166,22 @@ body {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-panel.is-refreshing .chart-canvas,
|
||||
.table-panel.is-refreshing .lot-table-wrap {
|
||||
animation: refreshPulse 0.9s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes refreshPulse {
|
||||
0% {
|
||||
opacity: 0.78;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.qc-gate-chart {
|
||||
min-height: 320px;
|
||||
padding: 10px 14px 2px;
|
||||
@@ -259,6 +281,16 @@ body {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.panel,
|
||||
.refresh-button,
|
||||
.chart-panel.is-refreshing .chart-canvas,
|
||||
.table-panel.is-refreshing .lot-table-wrap {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-number {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet, ensureMesApiAvailable } from '../core/api.js';
|
||||
import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js';
|
||||
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
|
||||
import { MATRIX_STATUS_COLUMNS, STATUS_DISPLAY_MAP, normalizeStatus } from '../resource-shared/constants.js';
|
||||
|
||||
import EquipmentGrid from './components/EquipmentGrid.vue';
|
||||
|
||||
4
frontend/src/shared-composables/index.js
Normal file
4
frontend/src/shared-composables/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useAutoRefresh } from './useAutoRefresh.js';
|
||||
export { useAutocomplete } from './useAutocomplete.js';
|
||||
export { usePaginationState } from './usePaginationState.js';
|
||||
export { readQueryState, writeQueryState } from './useQueryState.js';
|
||||
5
frontend/src/shared-composables/useAutoRefresh.js
Normal file
5
frontend/src/shared-composables/useAutoRefresh.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useAutoRefresh as useAutoRefreshBase } from '../wip-shared/composables/useAutoRefresh.js';
|
||||
|
||||
export function useAutoRefresh(options = {}) {
|
||||
return useAutoRefreshBase(options);
|
||||
}
|
||||
5
frontend/src/shared-composables/useAutocomplete.js
Normal file
5
frontend/src/shared-composables/useAutocomplete.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useAutocomplete as useAutocompleteBase } from '../wip-shared/composables/useAutocomplete.js';
|
||||
|
||||
export function useAutocomplete(options = {}) {
|
||||
return useAutocompleteBase(options);
|
||||
}
|
||||
35
frontend/src/shared-composables/usePaginationState.js
Normal file
35
frontend/src/shared-composables/usePaginationState.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
export function usePaginationState(initial = {}) {
|
||||
const page = ref(Number(initial.page || 1));
|
||||
const perPage = ref(Number(initial.perPage || 50));
|
||||
const total = ref(Number(initial.total || 0));
|
||||
const totalPages = ref(Number(initial.totalPages || 1));
|
||||
|
||||
const hasPrev = computed(() => page.value > 1);
|
||||
const hasNext = computed(() => page.value < totalPages.value);
|
||||
|
||||
function setFromPayload(pagination = {}) {
|
||||
page.value = Number(pagination.page || 1);
|
||||
perPage.value = Number(pagination.perPage || pagination.page_size || perPage.value || 50);
|
||||
total.value = Number(pagination.total || pagination.total_count || 0);
|
||||
totalPages.value = Number(pagination.totalPages || pagination.total_pages || 1);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
page.value = 1;
|
||||
total.value = 0;
|
||||
totalPages.value = 1;
|
||||
}
|
||||
|
||||
return {
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
totalPages,
|
||||
hasPrev,
|
||||
hasNext,
|
||||
setFromPayload,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
23
frontend/src/shared-composables/useQueryState.js
Normal file
23
frontend/src/shared-composables/useQueryState.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export function readQueryState(keys = []) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const state = {};
|
||||
keys.forEach((key) => {
|
||||
state[key] = params.get(key) || '';
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
export function writeQueryState(nextState = {}) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
Object.entries(nextState).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
params.delete(key);
|
||||
return;
|
||||
}
|
||||
params.set(key, String(value));
|
||||
});
|
||||
|
||||
const nextQuery = params.toString();
|
||||
const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}`;
|
||||
window.history.replaceState({}, '', nextUrl);
|
||||
}
|
||||
31
frontend/src/shared-ui/components/FilterToolbar.vue
Normal file
31
frontend/src/shared-ui/components/FilterToolbar.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<section class="shared-filter-toolbar" role="region" aria-label="filters">
|
||||
<div class="shared-filter-toolbar-main">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="shared-filter-toolbar-actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shared-filter-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.shared-filter-toolbar-main,
|
||||
.shared-filter-toolbar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
66
frontend/src/shared-ui/components/PaginationControl.vue
Normal file
66
frontend/src/shared-ui/components/PaginationControl.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import BasePagination from '../../wip-shared/components/Pagination.vue';
|
||||
|
||||
const props = defineProps({
|
||||
page: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
infoText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change', 'prev', 'next']);
|
||||
|
||||
const page = computed(() => {
|
||||
if (props.page !== null && props.page !== undefined) {
|
||||
return Number(props.page || 1);
|
||||
}
|
||||
return Number(props.modelValue || 1);
|
||||
});
|
||||
const safeTotalPages = computed(() => Math.max(Number(props.totalPages || 1), 1));
|
||||
|
||||
function toPage(nextPage, eventName) {
|
||||
if (nextPage < 1 || nextPage > safeTotalPages.value || nextPage === page.value) {
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', nextPage);
|
||||
emit('change', nextPage);
|
||||
emit(eventName, nextPage);
|
||||
}
|
||||
|
||||
function onPrev() {
|
||||
toPage(page.value - 1, 'prev');
|
||||
}
|
||||
|
||||
function onNext() {
|
||||
toPage(page.value + 1, 'next');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasePagination
|
||||
:visible="visible"
|
||||
:page="page"
|
||||
:total-pages="safeTotalPages"
|
||||
:info-text="infoText"
|
||||
@prev="onPrev"
|
||||
@next="onNext"
|
||||
/>
|
||||
</template>
|
||||
38
frontend/src/shared-ui/components/SectionCard.vue
Normal file
38
frontend/src/shared-ui/components/SectionCard.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<section class="shared-section-card">
|
||||
<header v-if="$slots.header" class="shared-section-card-header">
|
||||
<slot name="header" />
|
||||
</header>
|
||||
<div class="shared-section-card-body">
|
||||
<slot />
|
||||
</div>
|
||||
<footer v-if="$slots.footer" class="shared-section-card-footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shared-section-card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.shared-section-card-header,
|
||||
.shared-section-card-footer {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.shared-section-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.shared-section-card-header {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.shared-section-card-footer {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
</style>
|
||||
53
frontend/src/shared-ui/components/StatusBadge.vue
Normal file
53
frontend/src/shared-ui/components/StatusBadge.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
tone: {
|
||||
type: String,
|
||||
default: 'neutral',
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const badgeClass = computed(() => `shared-status-badge tone-${props.tone}`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="badgeClass">{{ text }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shared-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tone-neutral {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.tone-success {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.tone-warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.tone-danger {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
4
frontend/src/shared-ui/index.js
Normal file
4
frontend/src/shared-ui/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as FilterToolbar } from './components/FilterToolbar.vue';
|
||||
export { default as PaginationControl } from './components/PaginationControl.vue';
|
||||
export { default as SectionCard } from './components/SectionCard.vue';
|
||||
export { default as StatusBadge } from './components/StatusBadge.vue';
|
||||
68
frontend/src/styles/tailwind.css
Normal file
68
frontend/src/styles/tailwind.css
Normal file
@@ -0,0 +1,68 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--portal-shell-max-width: 1600px;
|
||||
--portal-shell-gap: 12px;
|
||||
--portal-shell-bg: #f5f7fa;
|
||||
--portal-panel-bg: #ffffff;
|
||||
--portal-text-primary: #1f2937;
|
||||
--portal-text-secondary: #64748b;
|
||||
--portal-brand-start: #667eea;
|
||||
--portal-brand-end: #764ba2;
|
||||
--portal-radius-shell: 10px;
|
||||
--portal-radius-card: 8px;
|
||||
--portal-shadow-soft: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
--portal-shadow-panel: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
--portal-z-overlay: 1000;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--portal-shell-bg);
|
||||
color: var(--portal-text-primary);
|
||||
font-family: "Noto Sans TC", "Microsoft JhengHei", system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.ui-shell-card {
|
||||
border-radius: var(--portal-radius-shell);
|
||||
background: var(--portal-panel-bg);
|
||||
box-shadow: var(--portal-shadow-panel);
|
||||
}
|
||||
|
||||
.ui-sidebar-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
border-radius: var(--portal-radius-card);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.ui-sidebar-link:hover {
|
||||
background: #f1f5f9;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.ui-sidebar-link.is-active {
|
||||
background: #eef2ff;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.u-panel-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--portal-shell-gap);
|
||||
}
|
||||
|
||||
.u-content-shell {
|
||||
max-width: var(--portal-shell-max-width);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
153
frontend/src/tmtt-defect/App.vue
Normal file
153
frontend/src/tmtt-defect/App.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import FilterToolbar from '../shared-ui/components/FilterToolbar.vue';
|
||||
import SectionCard from '../shared-ui/components/SectionCard.vue';
|
||||
import StatusBadge from '../shared-ui/components/StatusBadge.vue';
|
||||
import TmttChartCard from './components/TmttChartCard.vue';
|
||||
import TmttDetailTable from './components/TmttDetailTable.vue';
|
||||
import TmttKpiCards from './components/TmttKpiCards.vue';
|
||||
import { useTmttDefectData } from './composables/useTmttDefectData.js';
|
||||
|
||||
const {
|
||||
startDate,
|
||||
endDate,
|
||||
loading,
|
||||
errorMessage,
|
||||
hasData,
|
||||
kpi,
|
||||
charts,
|
||||
dailyTrend,
|
||||
filteredRows,
|
||||
totalCount,
|
||||
filteredCount,
|
||||
activeFilter,
|
||||
sortState,
|
||||
queryData,
|
||||
setFilter,
|
||||
clearFilter,
|
||||
toggleSort,
|
||||
exportCsv,
|
||||
} = useTmttDefectData();
|
||||
|
||||
const paretoCharts = [
|
||||
{ key: 'by_workflow', field: 'WORKFLOW', title: '依 WORKFLOW' },
|
||||
{ key: 'by_package', field: 'PRODUCTLINENAME', title: '依 PACKAGE' },
|
||||
{ key: 'by_type', field: 'PJ_TYPE', title: '依 TYPE' },
|
||||
{ key: 'by_tmtt_machine', field: 'TMTT_EQUIPMENTNAME', title: '依 TMTT 機台' },
|
||||
{ key: 'by_mold_machine', field: 'MOLD_EQUIPMENTNAME', title: '依 MOLD 機台' },
|
||||
];
|
||||
|
||||
const detailCountLabel = computed(() => {
|
||||
if (!activeFilter.value) {
|
||||
return `${filteredCount.value} 筆`;
|
||||
}
|
||||
return `${filteredCount.value} / ${totalCount.value} 筆`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tmtt-page u-content-shell">
|
||||
<header class="tmtt-header">
|
||||
<h1>TMTT 印字與腳型不良分析</h1>
|
||||
<p>Legacy rewrite exemplar:Vue 元件化 + Shared UI + Tailwind token layer</p>
|
||||
</header>
|
||||
|
||||
<div class="u-panel-stack">
|
||||
<SectionCard>
|
||||
<template #header>
|
||||
<div class="tmtt-block-title">查詢條件</div>
|
||||
</template>
|
||||
|
||||
<FilterToolbar>
|
||||
<label class="tmtt-field">
|
||||
<span>起始日期</span>
|
||||
<input v-model="startDate" type="date" />
|
||||
</label>
|
||||
<label class="tmtt-field">
|
||||
<span>結束日期</span>
|
||||
<input v-model="endDate" type="date" />
|
||||
</label>
|
||||
|
||||
<template #actions>
|
||||
<button type="button" class="tmtt-btn tmtt-btn-primary" :disabled="loading" @click="queryData">
|
||||
{{ loading ? '查詢中...' : '查詢' }}
|
||||
</button>
|
||||
<button type="button" class="tmtt-btn tmtt-btn-success" :disabled="loading" @click="exportCsv">
|
||||
匯出 CSV
|
||||
</button>
|
||||
</template>
|
||||
</FilterToolbar>
|
||||
</SectionCard>
|
||||
|
||||
<p v-if="errorMessage" class="tmtt-error-banner">{{ errorMessage }}</p>
|
||||
|
||||
<template v-if="hasData">
|
||||
<TmttKpiCards :kpi="kpi" />
|
||||
|
||||
<div class="tmtt-chart-grid">
|
||||
<TmttChartCard
|
||||
v-for="config in paretoCharts"
|
||||
:key="config.key"
|
||||
:title="config.title"
|
||||
mode="pareto"
|
||||
:field="config.field"
|
||||
:selected-value="activeFilter?.value || ''"
|
||||
:data="charts[config.key] || []"
|
||||
@select="setFilter"
|
||||
/>
|
||||
|
||||
<TmttChartCard
|
||||
title="每日印字不良率趨勢"
|
||||
mode="print-trend"
|
||||
:data="dailyTrend"
|
||||
line-label="印字不良率"
|
||||
line-color="#ef4444"
|
||||
/>
|
||||
|
||||
<TmttChartCard
|
||||
title="每日腳型不良率趨勢"
|
||||
mode="lead-trend"
|
||||
:data="dailyTrend"
|
||||
line-label="腳型不良率"
|
||||
line-color="#f59e0b"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionCard>
|
||||
<template #header>
|
||||
<div class="tmtt-detail-header">
|
||||
<div>
|
||||
<strong>明細清單</strong>
|
||||
<span class="tmtt-detail-count">({{ detailCountLabel }})</span>
|
||||
</div>
|
||||
<div class="tmtt-detail-actions">
|
||||
<StatusBadge
|
||||
v-if="activeFilter"
|
||||
tone="warning"
|
||||
:text="activeFilter.label"
|
||||
/>
|
||||
<button
|
||||
v-if="activeFilter"
|
||||
type="button"
|
||||
class="tmtt-btn tmtt-btn-ghost"
|
||||
@click="clearFilter"
|
||||
>
|
||||
清除篩選
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<TmttDetailTable :rows="filteredRows" :sort-state="sortState" @sort="toggleSort" />
|
||||
</SectionCard>
|
||||
</template>
|
||||
|
||||
<SectionCard v-else>
|
||||
<div class="tmtt-empty-state">
|
||||
<p>請選擇日期範圍後點擊「查詢」。</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
177
frontend/src/tmtt-defect/components/TmttChartCard.vue
Normal file
177
frontend/src/tmtt-defect/components/TmttChartCard.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import VChart from 'vue-echarts';
|
||||
import { use } from 'echarts/core';
|
||||
import { BarChart, LineChart } from 'echarts/charts';
|
||||
import { GridComponent, LegendComponent, TooltipComponent, TitleComponent } from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
use([CanvasRenderer, BarChart, LineChart, GridComponent, LegendComponent, TooltipComponent, TitleComponent]);
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'pareto',
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
field: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
lineLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
lineColor: {
|
||||
type: String,
|
||||
default: '#6366f1',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
function emptyOption() {
|
||||
return {
|
||||
title: {
|
||||
text: '無資料',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
textStyle: { color: '#94a3b8', fontSize: 14 },
|
||||
},
|
||||
xAxis: { show: false },
|
||||
yAxis: { show: false },
|
||||
series: [],
|
||||
};
|
||||
}
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const data = props.data || [];
|
||||
if (!data.length) {
|
||||
return emptyOption();
|
||||
}
|
||||
|
||||
if (props.mode === 'pareto') {
|
||||
const names = data.map((item) => item.name);
|
||||
const printRates = data.map((item) => Number(item.print_defect_rate || 0));
|
||||
const leadRates = data.map((item) => Number(item.lead_defect_rate || 0));
|
||||
const cumPct = data.map((item) => Number(item.cumulative_pct || 0));
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
},
|
||||
legend: { data: ['印字不良率', '腳型不良率', '累積%'], bottom: 0 },
|
||||
grid: { left: 56, right: 56, top: 24, bottom: names.length > 8 ? 90 : 56 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: names,
|
||||
axisLabel: {
|
||||
rotate: names.length > 8 ? 35 : 0,
|
||||
interval: 0,
|
||||
formatter: (value) => (value.length > 16 ? `${value.slice(0, 16)}...` : value),
|
||||
},
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '不良率(%)', splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
{ type: 'value', name: '累積%', max: 100 },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '印字不良率',
|
||||
type: 'bar',
|
||||
stack: 'defect',
|
||||
data: printRates,
|
||||
itemStyle: { color: '#ef4444' },
|
||||
barMaxWidth: 40,
|
||||
},
|
||||
{
|
||||
name: '腳型不良率',
|
||||
type: 'bar',
|
||||
stack: 'defect',
|
||||
data: leadRates,
|
||||
itemStyle: { color: '#f59e0b' },
|
||||
barMaxWidth: 40,
|
||||
},
|
||||
{
|
||||
name: '累積%',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: cumPct,
|
||||
itemStyle: { color: '#6366f1' },
|
||||
lineStyle: { width: 2 },
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const dates = data.map((item) => item.date);
|
||||
const lineValues = data.map((item) => Number(item[props.mode === 'print-trend' ? 'print_defect_rate' : 'lead_defect_rate'] || 0));
|
||||
const inputValues = data.map((item) => Number(item.input_qty || 0));
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: [props.lineLabel || '趨勢', '投入數'], bottom: 0 },
|
||||
grid: { left: 56, right: 56, top: 24, bottom: 56 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: { rotate: dates.length > 14 ? 35 : 0 },
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '不良率(%)', splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
{ type: 'value', name: '投入數' },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: props.lineLabel || '趨勢',
|
||||
type: 'line',
|
||||
data: lineValues,
|
||||
itemStyle: { color: props.lineColor },
|
||||
lineStyle: { width: 2 },
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
},
|
||||
{
|
||||
name: '投入數',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
data: inputValues,
|
||||
itemStyle: { color: '#c7d2fe' },
|
||||
barMaxWidth: 20,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
function handleClick(params) {
|
||||
if (props.mode !== 'pareto' || params?.componentType !== 'series' || !params?.name || !props.field) {
|
||||
return;
|
||||
}
|
||||
emit('select', {
|
||||
field: props.field,
|
||||
value: params.name,
|
||||
label: `${props.field}: ${params.name}`,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="tmtt-chart-card">
|
||||
<h3>{{ title }}</h3>
|
||||
<VChart class="tmtt-chart-canvas" :option="chartOption" autoresize @click="handleClick" />
|
||||
</article>
|
||||
</template>
|
||||
87
frontend/src/tmtt-defect/components/TmttDetailTable.vue
Normal file
87
frontend/src/tmtt-defect/components/TmttDetailTable.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
sortState: {
|
||||
type: Object,
|
||||
default: () => ({ column: '', asc: true }),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sort']);
|
||||
|
||||
const columns = computed(() => [
|
||||
{ key: 'CONTAINERNAME', label: 'LOT ID' },
|
||||
{ key: 'PJ_TYPE', label: 'TYPE' },
|
||||
{ key: 'PRODUCTLINENAME', label: 'PACKAGE' },
|
||||
{ key: 'WORKFLOW', label: 'WORKFLOW' },
|
||||
{ key: 'FINISHEDRUNCARD', label: '完工流水碼' },
|
||||
{ key: 'TMTT_EQUIPMENTNAME', label: 'TMTT設備' },
|
||||
{ key: 'MOLD_EQUIPMENTNAME', label: 'MOLD設備' },
|
||||
{ key: 'INPUT_QTY', label: '投入數', numeric: true },
|
||||
{ key: 'PRINT_DEFECT_QTY', label: '印字不良', numeric: true, danger: true },
|
||||
{ key: 'PRINT_DEFECT_RATE', label: '印字不良率(%)', numeric: true, danger: true, decimal: 4 },
|
||||
{ key: 'LEAD_DEFECT_QTY', label: '腳型不良', numeric: true, warning: true },
|
||||
{ key: 'LEAD_DEFECT_RATE', label: '腳型不良率(%)', numeric: true, warning: true, decimal: 4 },
|
||||
]);
|
||||
|
||||
function formatNumber(value, decimal = null) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
if (Number.isInteger(decimal) && decimal >= 0) {
|
||||
return n.toFixed(decimal);
|
||||
}
|
||||
return n.toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function sortIndicator(key) {
|
||||
if (props.sortState?.column !== key) {
|
||||
return '';
|
||||
}
|
||||
return props.sortState?.asc ? '▲' : '▼';
|
||||
}
|
||||
|
||||
function cellClass(column) {
|
||||
const classes = [];
|
||||
if (column.numeric) classes.push('is-numeric');
|
||||
if (column.danger) classes.push('is-danger');
|
||||
if (column.warning) classes.push('is-warning');
|
||||
return classes;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tmtt-detail-table-wrap">
|
||||
<table class="tmtt-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in columns" :key="column.key">
|
||||
<button type="button" class="tmtt-sort-btn" @click="emit('sort', column.key)">
|
||||
{{ column.label }}
|
||||
<span class="tmtt-sort-indicator">{{ sortIndicator(column.key) }}</span>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="rows.length === 0">
|
||||
<td :colspan="columns.length" class="tmtt-empty-row">無資料</td>
|
||||
</tr>
|
||||
<tr v-for="(row, index) in rows" v-else :key="`${row.CONTAINERNAME || 'row'}-${index}`">
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="`${row.CONTAINERNAME || 'row'}-${column.key}-${index}`"
|
||||
:class="cellClass(column)"
|
||||
>
|
||||
<template v-if="column.numeric">{{ formatNumber(row[column.key], column.decimal) }}</template>
|
||||
<template v-else>{{ row[column.key] || '' }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
51
frontend/src/tmtt-defect/components/TmttKpiCards.vue
Normal file
51
frontend/src/tmtt-defect/components/TmttKpiCards.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
kpi: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
function fmtNumber(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '-';
|
||||
return n.toLocaleString('zh-TW');
|
||||
}
|
||||
|
||||
function fmtRate(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '-';
|
||||
return n.toFixed(4);
|
||||
}
|
||||
|
||||
const cards = computed(() => {
|
||||
const value = props.kpi || {};
|
||||
return [
|
||||
{ key: 'total_input', label: '投入數', display: fmtNumber(value.total_input), tone: 'neutral' },
|
||||
{ key: 'lot_count', label: 'LOT 數', display: fmtNumber(value.lot_count), tone: 'neutral' },
|
||||
{ key: 'print_defect_qty', label: '印字不良數', display: fmtNumber(value.print_defect_qty), tone: 'danger' },
|
||||
{ key: 'print_defect_rate', label: '印字不良率', display: fmtRate(value.print_defect_rate), unit: '%', tone: 'danger' },
|
||||
{ key: 'lead_defect_qty', label: '腳型不良數', display: fmtNumber(value.lead_defect_qty), tone: 'warning' },
|
||||
{ key: 'lead_defect_rate', label: '腳型不良率', display: fmtRate(value.lead_defect_rate), unit: '%', tone: 'warning' },
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tmtt-kpi-grid">
|
||||
<article
|
||||
v-for="card in cards"
|
||||
:key="card.key"
|
||||
class="tmtt-kpi-card"
|
||||
:class="`tone-${card.tone}`"
|
||||
>
|
||||
<p class="tmtt-kpi-label">{{ card.label }}</p>
|
||||
<p class="tmtt-kpi-value">
|
||||
{{ card.display }}
|
||||
<span v-if="card.unit" class="tmtt-kpi-unit">{{ card.unit }}</span>
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
216
frontend/src/tmtt-defect/composables/useTmttDefectData.js
Normal file
216
frontend/src/tmtt-defect/composables/useTmttDefectData.js
Normal file
@@ -0,0 +1,216 @@
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { apiGet, ensureMesApiAvailable } from '../../core/api.js';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const NUMERIC_COLUMNS = new Set([
|
||||
'INPUT_QTY',
|
||||
'PRINT_DEFECT_QTY',
|
||||
'PRINT_DEFECT_RATE',
|
||||
'LEAD_DEFECT_QTY',
|
||||
'LEAD_DEFECT_RATE',
|
||||
]);
|
||||
|
||||
function notify(level, message) {
|
||||
const toast = globalThis.Toast;
|
||||
if (toast && typeof toast[level] === 'function') {
|
||||
return toast[level](message);
|
||||
}
|
||||
if (level === 'error') {
|
||||
console.error(message);
|
||||
} else {
|
||||
console.info(message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dismissToast(id) {
|
||||
if (!id) return;
|
||||
const toast = globalThis.Toast;
|
||||
if (toast && typeof toast.dismiss === 'function') {
|
||||
toast.dismiss(id);
|
||||
}
|
||||
}
|
||||
|
||||
function toComparable(value, key) {
|
||||
if (NUMERIC_COLUMNS.has(key)) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
if (value == null) return '';
|
||||
return String(value).toUpperCase();
|
||||
}
|
||||
|
||||
function toDateString(date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function useTmttDefectData() {
|
||||
const startDate = ref('');
|
||||
const endDate = ref('');
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const analysisData = ref(null);
|
||||
const activeFilter = ref(null);
|
||||
const sortState = ref({ column: '', asc: true });
|
||||
|
||||
function initializeDateRange() {
|
||||
if (startDate.value && endDate.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - 6);
|
||||
|
||||
startDate.value = toDateString(start);
|
||||
endDate.value = toDateString(end);
|
||||
}
|
||||
|
||||
const hasData = computed(() => Boolean(analysisData.value));
|
||||
const kpi = computed(() => analysisData.value?.kpi || null);
|
||||
const charts = computed(() => analysisData.value?.charts || {});
|
||||
const dailyTrend = computed(() => analysisData.value?.daily_trend || []);
|
||||
const rawDetailRows = computed(() => analysisData.value?.detail || []);
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
let rows = rawDetailRows.value;
|
||||
|
||||
if (activeFilter.value?.field && activeFilter.value?.value) {
|
||||
rows = rows.filter((row) => String(row?.[activeFilter.value.field] || '') === activeFilter.value.value);
|
||||
}
|
||||
|
||||
if (!sortState.value.column) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const sorted = [...rows].sort((left, right) => {
|
||||
const leftValue = toComparable(left?.[sortState.value.column], sortState.value.column);
|
||||
const rightValue = toComparable(right?.[sortState.value.column], sortState.value.column);
|
||||
if (leftValue < rightValue) {
|
||||
return sortState.value.asc ? -1 : 1;
|
||||
}
|
||||
if (leftValue > rightValue) {
|
||||
return sortState.value.asc ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const totalCount = computed(() => rawDetailRows.value.length);
|
||||
const filteredCount = computed(() => filteredRows.value.length);
|
||||
|
||||
async function queryData() {
|
||||
if (!startDate.value || !endDate.value) {
|
||||
notify('warning', '請選擇起始和結束日期');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
const loadingToastId = notify('loading', '查詢中...');
|
||||
|
||||
try {
|
||||
const result = await apiGet('/api/tmtt-defect/analysis', {
|
||||
params: {
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
},
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
if (!result || !result.success) {
|
||||
const message = result?.error || '查詢失敗';
|
||||
errorMessage.value = message;
|
||||
notify('error', message);
|
||||
return;
|
||||
}
|
||||
|
||||
analysisData.value = result.data;
|
||||
activeFilter.value = null;
|
||||
sortState.value = { column: '', asc: true };
|
||||
notify('success', '查詢完成');
|
||||
} catch (error) {
|
||||
const message = error?.message || '查詢失敗';
|
||||
errorMessage.value = message;
|
||||
notify('error', `查詢失敗: ${message}`);
|
||||
} finally {
|
||||
dismissToast(loadingToastId);
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setFilter({ field, value, label }) {
|
||||
if (!field || !value) {
|
||||
return;
|
||||
}
|
||||
activeFilter.value = {
|
||||
field,
|
||||
value,
|
||||
label: label || `${field}: ${value}`,
|
||||
};
|
||||
}
|
||||
|
||||
function clearFilter() {
|
||||
activeFilter.value = null;
|
||||
}
|
||||
|
||||
function toggleSort(column) {
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sortState.value.column === column) {
|
||||
sortState.value = {
|
||||
column,
|
||||
asc: !sortState.value.asc,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
sortState.value = {
|
||||
column,
|
||||
asc: true,
|
||||
};
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
if (!startDate.value || !endDate.value) {
|
||||
notify('warning', '請先查詢資料');
|
||||
return;
|
||||
}
|
||||
|
||||
const query = new URLSearchParams({
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
});
|
||||
window.open(`/api/tmtt-defect/export?${query.toString()}`, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
initializeDateRange();
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
loading,
|
||||
errorMessage,
|
||||
hasData,
|
||||
kpi,
|
||||
charts,
|
||||
dailyTrend,
|
||||
rawDetailRows,
|
||||
filteredRows,
|
||||
totalCount,
|
||||
filteredCount,
|
||||
activeFilter,
|
||||
sortState,
|
||||
queryData,
|
||||
setFilter,
|
||||
clearFilter,
|
||||
toggleSort,
|
||||
exportCsv,
|
||||
};
|
||||
}
|
||||
@@ -1,363 +1,7 @@
|
||||
import { ensureMesApiAvailable } from '../core/api.js';
|
||||
import { createApp } from 'vue';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
import '../styles/tailwind.css';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
|
||||
(function() {
|
||||
// ============================================================
|
||||
// State
|
||||
// ============================================================
|
||||
let analysisData = null;
|
||||
let activeFilter = null; // { dimension: 'by_workflow', field: 'WORKFLOW', value: 'xxx' }
|
||||
let sortState = { column: null, asc: true };
|
||||
const charts = {};
|
||||
|
||||
const CHART_CONFIG = [
|
||||
{ id: 'chartWorkflow', key: 'by_workflow', field: 'WORKFLOW', title: 'WORKFLOW' },
|
||||
{ id: 'chartPackage', key: 'by_package', field: 'PRODUCTLINENAME', title: 'PACKAGE' },
|
||||
{ id: 'chartType', key: 'by_type', field: 'PJ_TYPE', title: 'TYPE' },
|
||||
{ id: 'chartTmtt', key: 'by_tmtt_machine', field: 'TMTT_EQUIPMENTNAME', title: 'TMTT機台' },
|
||||
{ id: 'chartMold', key: 'by_mold_machine', field: 'MOLD_EQUIPMENTNAME', title: 'MOLD機台' },
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// Query
|
||||
// ============================================================
|
||||
window.executeQuery = async function() {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
Toast.warning('請選擇起始和結束日期');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btnQuery');
|
||||
btn.disabled = true;
|
||||
const loadingId = Toast.loading('查詢中...');
|
||||
|
||||
try {
|
||||
const result = await MesApi.get('/api/tmtt-defect/analysis', {
|
||||
params: { start_date: startDate, end_date: endDate },
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
Toast.dismiss(loadingId);
|
||||
|
||||
if (!result || !result.success) {
|
||||
Toast.error(result?.error || '查詢失敗');
|
||||
return;
|
||||
}
|
||||
|
||||
analysisData = result.data;
|
||||
activeFilter = null;
|
||||
sortState = { column: null, asc: true };
|
||||
|
||||
renderAll();
|
||||
Toast.success('查詢完成');
|
||||
} catch (err) {
|
||||
Toast.dismiss(loadingId);
|
||||
Toast.error('查詢失敗: ' + (err.message || '未知錯誤'));
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Render
|
||||
// ============================================================
|
||||
function renderAll() {
|
||||
if (!analysisData) return;
|
||||
|
||||
document.getElementById('emptyState').style.display = 'none';
|
||||
document.getElementById('kpiRow').style.display = '';
|
||||
document.getElementById('chartGrid').style.display = '';
|
||||
document.getElementById('detailSection').style.display = '';
|
||||
|
||||
renderKpi(analysisData.kpi);
|
||||
renderCharts(analysisData.charts);
|
||||
renderDailyTrend(analysisData.daily_trend || []);
|
||||
renderDetailTable();
|
||||
}
|
||||
|
||||
function renderKpi(kpi) {
|
||||
document.getElementById('kpiInput').textContent = kpi.total_input.toLocaleString('zh-TW');
|
||||
document.getElementById('kpiLots').textContent = kpi.lot_count.toLocaleString('zh-TW');
|
||||
document.getElementById('kpiPrintQty').textContent = kpi.print_defect_qty.toLocaleString('zh-TW');
|
||||
document.getElementById('kpiPrintRate').innerHTML = kpi.print_defect_rate.toFixed(4) + '<span class="kpi-unit">%</span>';
|
||||
document.getElementById('kpiLeadQty').textContent = kpi.lead_defect_qty.toLocaleString('zh-TW');
|
||||
document.getElementById('kpiLeadRate').innerHTML = kpi.lead_defect_rate.toFixed(4) + '<span class="kpi-unit">%</span>';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Charts
|
||||
// ============================================================
|
||||
function renderCharts(chartsData) {
|
||||
CHART_CONFIG.forEach(cfg => {
|
||||
const data = chartsData[cfg.key] || [];
|
||||
renderParetoChart(cfg.id, data, cfg.key, cfg.field, cfg.title);
|
||||
});
|
||||
}
|
||||
|
||||
function renderParetoChart(containerId, data, chartKey, filterField, title) {
|
||||
if (!charts[containerId]) {
|
||||
charts[containerId] = echarts.init(document.getElementById(containerId));
|
||||
}
|
||||
const chart = charts[containerId];
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
chart.setOption({
|
||||
title: { text: '無資料', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 14 } },
|
||||
xAxis: { show: false }, yAxis: { show: false }, series: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const names = data.map(d => d.name);
|
||||
const printRates = data.map(d => d.print_defect_rate);
|
||||
const leadRates = data.map(d => d.lead_defect_rate);
|
||||
const cumPct = data.map(d => d.cumulative_pct);
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: function(params) {
|
||||
const name = params[0].name;
|
||||
const item = data.find(d => d.name === name);
|
||||
if (!item) return name;
|
||||
return `<b>${name}</b><br/>` +
|
||||
`投入數: ${item.input_qty.toLocaleString()}<br/>` +
|
||||
`<span style="color:${getComputedStyle(document.documentElement).getPropertyValue('--print-color')}">●</span> 印字不良: ${item.print_defect_qty} (${item.print_defect_rate.toFixed(4)}%)<br/>` +
|
||||
`<span style="color:${getComputedStyle(document.documentElement).getPropertyValue('--lead-color')}">●</span> 腳型不良: ${item.lead_defect_qty} (${item.lead_defect_rate.toFixed(4)}%)<br/>` +
|
||||
`累積: ${item.cumulative_pct.toFixed(1)}%`;
|
||||
}
|
||||
},
|
||||
legend: { data: ['印字不良率', '腳型不良率', '累積%'], bottom: 0, textStyle: { fontSize: 11 } },
|
||||
grid: { left: 60, right: 60, top: 30, bottom: names.length > 8 ? 100 : 60 },
|
||||
xAxis: {
|
||||
type: 'category', data: names,
|
||||
axisLabel: {
|
||||
rotate: names.length > 8 ? 35 : 0,
|
||||
fontSize: 11,
|
||||
interval: 0,
|
||||
formatter: v => v.length > 16 ? v.slice(0, 16) + '...' : v
|
||||
}
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '不良率(%)', axisLabel: { fontSize: 10 }, splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
{ type: 'value', name: '累積%', max: 100, axisLabel: { fontSize: 10 } }
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '印字不良率', type: 'bar', stack: 'defect',
|
||||
data: printRates,
|
||||
itemStyle: { color: '#ef4444' },
|
||||
barMaxWidth: 40,
|
||||
},
|
||||
{
|
||||
name: '腳型不良率', type: 'bar', stack: 'defect',
|
||||
data: leadRates,
|
||||
itemStyle: { color: '#f59e0b' },
|
||||
barMaxWidth: 40,
|
||||
},
|
||||
{
|
||||
name: '累積%', type: 'line', yAxisIndex: 1,
|
||||
data: cumPct,
|
||||
itemStyle: { color: '#6366f1' },
|
||||
lineStyle: { width: 2 },
|
||||
symbol: 'circle', symbolSize: 6,
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
chart.setOption(option, true);
|
||||
|
||||
// Drill-down click handler
|
||||
chart.off('click');
|
||||
chart.on('click', function(params) {
|
||||
if (params.componentType === 'series' && params.name) {
|
||||
setFilter(chartKey, filterField, params.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Daily Trend Charts
|
||||
// ============================================================
|
||||
function renderDailyTrend(trendData) {
|
||||
renderTrendChart('chartPrintTrend', trendData, 'print_defect_rate', '印字不良率', '#ef4444');
|
||||
renderTrendChart('chartLeadTrend', trendData, 'lead_defect_rate', '腳型不良率', '#f59e0b');
|
||||
}
|
||||
|
||||
function renderTrendChart(containerId, data, rateKey, label, color) {
|
||||
if (!charts[containerId]) {
|
||||
charts[containerId] = echarts.init(document.getElementById(containerId));
|
||||
}
|
||||
const chart = charts[containerId];
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
chart.setOption({
|
||||
title: { text: '無資料', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 14 } },
|
||||
xAxis: { show: false }, yAxis: { show: false }, series: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dates = data.map(d => d.date);
|
||||
const rates = data.map(d => d[rateKey]);
|
||||
const qtys = data.map(d => d[rateKey === 'print_defect_rate' ? 'print_defect_qty' : 'lead_defect_qty']);
|
||||
const inputs = data.map(d => d.input_qty);
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function(params) {
|
||||
const idx = params[0].dataIndex;
|
||||
const d = data[idx];
|
||||
return `<b>${d.date}</b><br/>` +
|
||||
`投入數: ${d.input_qty.toLocaleString()}<br/>` +
|
||||
`<span style="color:${color}">●</span> ${label}: ${d[rateKey].toFixed(4)}%<br/>` +
|
||||
`不良數: ${qtys[idx].toLocaleString()}`;
|
||||
}
|
||||
},
|
||||
legend: { data: [label, '投入數'], bottom: 0, textStyle: { fontSize: 11 } },
|
||||
grid: { left: 60, right: 60, top: 30, bottom: 50 },
|
||||
xAxis: {
|
||||
type: 'category', data: dates,
|
||||
axisLabel: { fontSize: 11, rotate: dates.length > 15 ? 35 : 0 }
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '不良率(%)', axisLabel: { fontSize: 10 }, splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
{ type: 'value', name: '投入數', axisLabel: { fontSize: 10 } }
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: label, type: 'line', data: rates,
|
||||
itemStyle: { color: color },
|
||||
lineStyle: { width: 2 },
|
||||
symbol: 'circle', symbolSize: 4,
|
||||
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: color + '33' }, { offset: 1, color: color + '05' }] } },
|
||||
},
|
||||
{
|
||||
name: '投入數', type: 'bar', yAxisIndex: 1,
|
||||
data: inputs,
|
||||
itemStyle: { color: '#e0e7ff' },
|
||||
barMaxWidth: 20,
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
chart.setOption(option, true);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Filter / Drill-down
|
||||
// ============================================================
|
||||
function setFilter(chartKey, field, value) {
|
||||
activeFilter = { dimension: chartKey, field: field, value: value };
|
||||
renderDetailTable();
|
||||
}
|
||||
|
||||
window.clearFilter = function() {
|
||||
activeFilter = null;
|
||||
renderDetailTable();
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Detail Table
|
||||
// ============================================================
|
||||
function renderDetailTable() {
|
||||
if (!analysisData) return;
|
||||
|
||||
let rows = analysisData.detail;
|
||||
|
||||
// Apply filter
|
||||
const filterTag = document.getElementById('filterTag');
|
||||
const btnClear = document.getElementById('btnClear');
|
||||
|
||||
if (activeFilter) {
|
||||
rows = rows.filter(r => (r[activeFilter.field] || '') === activeFilter.value);
|
||||
document.getElementById('filterLabel').textContent =
|
||||
`${activeFilter.field}: ${activeFilter.value}`;
|
||||
filterTag.style.display = '';
|
||||
btnClear.style.display = '';
|
||||
} else {
|
||||
filterTag.style.display = 'none';
|
||||
btnClear.style.display = 'none';
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
if (sortState.column) {
|
||||
const col = sortState.column;
|
||||
const asc = sortState.asc;
|
||||
rows = [...rows].sort((a, b) => {
|
||||
const va = a[col] ?? '';
|
||||
const vb = b[col] ?? '';
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
return asc ? va - vb : vb - va;
|
||||
}
|
||||
return asc ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('detailCount').textContent = `(${rows.length} 筆)`;
|
||||
|
||||
const tbody = document.getElementById('detailBody');
|
||||
if (rows.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="12" style="text-align:center;padding:20px;color:#999;">無資料</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rows.map(r => `<tr>
|
||||
<td>${r.CONTAINERNAME || ''}</td>
|
||||
<td>${r.PJ_TYPE || ''}</td>
|
||||
<td>${r.PRODUCTLINENAME || ''}</td>
|
||||
<td>${r.WORKFLOW || ''}</td>
|
||||
<td>${r.FINISHEDRUNCARD || ''}</td>
|
||||
<td>${r.TMTT_EQUIPMENTNAME || ''}</td>
|
||||
<td>${r.MOLD_EQUIPMENTNAME || ''}</td>
|
||||
<td style="text-align:right">${(r.INPUT_QTY || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right;color:var(--print-color)">${r.PRINT_DEFECT_QTY || 0}</td>
|
||||
<td style="text-align:right;color:var(--print-color)">${(r.PRINT_DEFECT_RATE || 0).toFixed(4)}</td>
|
||||
<td style="text-align:right;color:var(--lead-color)">${r.LEAD_DEFECT_QTY || 0}</td>
|
||||
<td style="text-align:right;color:var(--lead-color)">${(r.LEAD_DEFECT_RATE || 0).toFixed(4)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
// Update sort indicators
|
||||
document.querySelectorAll('.sort-indicator').forEach(el => el.textContent = '');
|
||||
if (sortState.column) {
|
||||
const ind = document.getElementById('sort_' + sortState.column);
|
||||
if (ind) ind.textContent = sortState.asc ? '▲' : '▼';
|
||||
}
|
||||
}
|
||||
|
||||
window.sortTable = function(column) {
|
||||
if (sortState.column === column) {
|
||||
sortState.asc = !sortState.asc;
|
||||
} else {
|
||||
sortState.column = column;
|
||||
sortState.asc = true;
|
||||
}
|
||||
renderDetailTable();
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// CSV Export
|
||||
// ============================================================
|
||||
window.exportCsv = function() {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
if (!startDate || !endDate) {
|
||||
Toast.warning('請先查詢資料');
|
||||
return;
|
||||
}
|
||||
window.open(`/api/tmtt-defect/export?start_date=${startDate}&end_date=${endDate}`, '_blank');
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Resize
|
||||
// ============================================================
|
||||
window.addEventListener('resize', function() {
|
||||
Object.values(charts).forEach(c => c.resize());
|
||||
});
|
||||
})();
|
||||
createApp(App).mount('#app');
|
||||
|
||||
281
frontend/src/tmtt-defect/style.css
Normal file
281
frontend/src/tmtt-defect/style.css
Normal file
@@ -0,0 +1,281 @@
|
||||
.tmtt-page {
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.tmtt-header {
|
||||
border-radius: 12px;
|
||||
padding: 22px 24px;
|
||||
margin-bottom: 16px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--portal-brand-start) 0%, var(--portal-brand-end) 100%);
|
||||
box-shadow: var(--portal-shadow-panel);
|
||||
}
|
||||
|
||||
.tmtt-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.tmtt-header p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.tmtt-block-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tmtt-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.tmtt-field input {
|
||||
height: 34px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tmtt-field input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.tmtt-btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tmtt-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tmtt-btn-primary {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tmtt-btn-primary:hover:not(:disabled) {
|
||||
background: #4338ca;
|
||||
}
|
||||
|
||||
.tmtt-btn-success {
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tmtt-btn-success:hover:not(:disabled) {
|
||||
background: #15803d;
|
||||
}
|
||||
|
||||
.tmtt-btn-ghost {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.tmtt-btn-ghost:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.tmtt-error-banner {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tmtt-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tmtt-kpi-card {
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
border-left: 4px solid #64748b;
|
||||
background: #fff;
|
||||
box-shadow: var(--portal-shadow-soft);
|
||||
}
|
||||
|
||||
.tmtt-kpi-card.tone-danger {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.tmtt-kpi-card.tone-warning {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.tmtt-kpi-label {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tmtt-kpi-value {
|
||||
margin: 8px 0 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.tmtt-kpi-unit {
|
||||
font-size: 12px;
|
||||
margin-left: 4px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tmtt-chart-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tmtt-chart-card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.tmtt-chart-card h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tmtt-chart-canvas {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tmtt-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tmtt-detail-count {
|
||||
margin-left: 6px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tmtt-detail-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tmtt-detail-table-wrap {
|
||||
overflow: auto;
|
||||
max-height: 520px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tmtt-detail-table {
|
||||
width: 100%;
|
||||
min-width: 1500px;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tmtt-detail-table th,
|
||||
.tmtt-detail-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tmtt-detail-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.tmtt-sort-btn {
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tmtt-sort-indicator {
|
||||
color: #64748b;
|
||||
min-width: 10px;
|
||||
}
|
||||
|
||||
.tmtt-detail-table tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.tmtt-detail-table .is-numeric {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tmtt-detail-table .is-danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.tmtt-detail-table .is-warning {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.tmtt-empty-row {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.tmtt-empty-state {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.tmtt-kpi-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.tmtt-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tmtt-kpi-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.tmtt-detail-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet } from '../core/api.js';
|
||||
import { buildWipDetailQueryParams } from '../core/wip-derive.js';
|
||||
import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js';
|
||||
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
|
||||
|
||||
import FilterPanel from './components/FilterPanel.vue';
|
||||
import LotDetailPanel from './components/LotDetailPanel.vue';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { reactive, watch } from 'vue';
|
||||
|
||||
import { apiGet } from '../../core/api.js';
|
||||
import { useAutocomplete } from '../../wip-shared/composables/useAutocomplete.js';
|
||||
import { useAutocomplete } from '../../shared-composables/useAutocomplete.js';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import Pagination from '../../wip-shared/components/Pagination.vue';
|
||||
import Pagination from '../../shared-ui/components/PaginationControl.vue';
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
buildWipOverviewQueryParams,
|
||||
splitHoldByType,
|
||||
} from '../core/wip-derive.js';
|
||||
import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js';
|
||||
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
|
||||
|
||||
import FilterPanel from './components/FilterPanel.vue';
|
||||
import MatrixTable from './components/MatrixTable.vue';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { reactive, watch } from 'vue';
|
||||
|
||||
import { apiGet } from '../../core/api.js';
|
||||
import { useAutocomplete } from '../../wip-shared/composables/useAutocomplete.js';
|
||||
import { useAutocomplete } from '../../shared-composables/useAutocomplete.js';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
@@ -111,23 +111,23 @@ function onSelect(field, value) {
|
||||
<button type="button" class="btn-primary" @click="applyFilters">套用篩選</button>
|
||||
<button type="button" class="btn-secondary" @click="clearFilters">清除篩選</button>
|
||||
|
||||
<div class="active-filters">
|
||||
<span v-if="filters.workorder" class="filter-tag">
|
||||
<TransitionGroup name="filter-chip" tag="div" class="active-filters">
|
||||
<span v-if="filters.workorder" key="workorder" class="filter-tag">
|
||||
WO: {{ filters.workorder }}
|
||||
<span class="remove" @click="removeFilter('workorder')">×</span>
|
||||
</span>
|
||||
<span v-if="filters.lotid" class="filter-tag">
|
||||
<span v-if="filters.lotid" key="lotid" class="filter-tag">
|
||||
Lot: {{ filters.lotid }}
|
||||
<span class="remove" @click="removeFilter('lotid')">×</span>
|
||||
</span>
|
||||
<span v-if="filters.package" class="filter-tag">
|
||||
<span v-if="filters.package" key="package" class="filter-tag">
|
||||
Pkg: {{ filters.package }}
|
||||
<span class="remove" @click="removeFilter('package')">×</span>
|
||||
</span>
|
||||
<span v-if="filters.type" class="filter-tag">
|
||||
<span v-if="filters.type" key="type" class="filter-tag">
|
||||
Type: {{ filters.type }}
|
||||
<span class="remove" @click="removeFilter('type')">×</span>
|
||||
</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -125,6 +125,24 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.filter-chip-enter-active,
|
||||
.filter-chip-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-chip-enter-from,
|
||||
.filter-chip-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.96);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.filter-chip-enter-active,
|
||||
.filter-chip-leave-active {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.overview-summary-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
62
frontend/tailwind.config.js
Normal file
62
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx,html}',
|
||||
'../src/mes_dashboard/templates/**/*.html'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
500: '#667eea',
|
||||
600: '#5a67d8',
|
||||
700: '#4c51bf'
|
||||
},
|
||||
accent: {
|
||||
500: '#764ba2'
|
||||
},
|
||||
surface: {
|
||||
app: '#f5f7fa',
|
||||
card: '#ffffff',
|
||||
muted: '#f8fafc'
|
||||
},
|
||||
stroke: {
|
||||
soft: '#e2e8f0',
|
||||
panel: '#e3e8f2'
|
||||
},
|
||||
state: {
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
neutral: '#9ca3af'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"Noto Sans TC"', '"Microsoft JhengHei"', 'system-ui', 'sans-serif']
|
||||
},
|
||||
spacing: {
|
||||
shell: '20px',
|
||||
panel: '24px',
|
||||
nav: '14px',
|
||||
block: '12px'
|
||||
},
|
||||
borderRadius: {
|
||||
shell: '10px',
|
||||
card: '8px'
|
||||
},
|
||||
boxShadow: {
|
||||
shell: '0 4px 12px rgba(102, 126, 234, 0.3)',
|
||||
panel: '0 2px 10px rgba(0, 0, 0, 0.08)',
|
||||
soft: '0 1px 4px rgba(0, 0, 0, 0.06)'
|
||||
},
|
||||
zIndex: {
|
||||
popup: '1000'
|
||||
}
|
||||
}
|
||||
},
|
||||
corePlugins: {
|
||||
preflight: false
|
||||
}
|
||||
};
|
||||
@@ -13,6 +13,7 @@ export default defineConfig(({ mode }) => ({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
portal: resolve(__dirname, 'src/portal/main.js'),
|
||||
'portal-shell': resolve(__dirname, 'src/portal-shell/index.html'),
|
||||
'wip-overview': resolve(__dirname, 'src/wip-overview/index.html'),
|
||||
'wip-detail': resolve(__dirname, 'src/wip-detail/index.html'),
|
||||
'hold-detail': resolve(__dirname, 'src/hold-detail/index.html'),
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-11
|
||||
@@ -0,0 +1,172 @@
|
||||
## Context
|
||||
|
||||
目前 `portal.html` 透過 `iframe + frame_id + toolFrame` 在同一頁面切換多個報表。此模式雖可避免整頁跳轉,但帶來以下問題:
|
||||
|
||||
- 內容生命週期拆成多個 frame,除錯與事件追蹤困難
|
||||
- 導覽邏輯被 iframe lazy-load、高度同步、active frame 狀態綁死
|
||||
- 測試對 DOM/互動契約依賴 iframe 結構,變更成本高
|
||||
- 已完成 Vite 模組化的頁面其實已可獨立路由載入,不需要 iframe 承載
|
||||
|
||||
此外,`drawers` 設定在目前環境已不是初始預設值,而是營運中配置(來源:`data/page_status.json`):
|
||||
|
||||
- `reports`(即時報表,order=1,admin_only=false)
|
||||
- `drawer-2`(歷史報表,order=2,admin_only=false)
|
||||
- `drawer`(查詢工具,order=3,admin_only=false)
|
||||
- `dev-tools`(開發工具,order=4,admin_only=true)
|
||||
|
||||
對應頁面已分散配置在上述抽屜,例如:
|
||||
|
||||
- 即時報表:`/wip-overview`、`/hold-overview`、`/resource`、`/qc-gate`
|
||||
- 歷史報表:`/hold-history`、`/resource-history`
|
||||
- 查詢工具:`/job-query`
|
||||
- 開發工具:admin pages 與部分工具頁(含 `tables`、`excel-query`、`query-tool`、`tmtt-defect`、`mid-section-defect`)
|
||||
|
||||
因此本次改造不只是移除 iframe,而是要把「抽屜資訊架構」從「載入技術(frame)耦合」解耦到「路由與權限治理」。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 移除 portal 內容區的 iframe 依賴與 frame 管理邏輯
|
||||
- 保留抽屜分組、admin 權限過濾、健康狀態檢查 UI
|
||||
- 將側欄點擊行為改為同視窗路由導頁
|
||||
- 維持既有 route path 與頁面業務邏輯不變
|
||||
- 更新測試使其驗證新契約(link/navigation)
|
||||
- 在不破壞既有 `drawers/pages` 資料模型下,支持 Router-based 導覽
|
||||
|
||||
**Non-Goals:**
|
||||
- 不在第一階段一次重寫全部 legacy 頁面內容
|
||||
- 不調整後端 API 介面或權限模型
|
||||
- 不更動 page_status.json 的資料模型(僅調整 portal 消費方式)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: 抽屜保留後端治理,前端改為 Router-aware 導覽
|
||||
- **選擇**: `get_navigation_config()` 仍作為抽屜與頁面來源,前端側欄只消費 route / status / admin_only,不再消費 frame_id/toolFrame。
|
||||
- **理由**:
|
||||
- 保留既有營運中的抽屜設定與管理流程
|
||||
- 抽屜責任回到 IA(分類、排序、權限),避免綁定載入技術
|
||||
- 降低一次性資料遷移與管理頁調整風險
|
||||
- **備選方案**:
|
||||
- 改由前端硬編抽屜:短期可行,但與管理後台脫鉤,營運成本增加
|
||||
|
||||
### Decision 2: 導入 SPA Shell + Vue Router,分階段替換多入口模式
|
||||
- **選擇**: 建立 SPA shell 承接抽屜導覽,主要報表頁優先轉為 router view;既有可獨立頁先保持可直接訪問。
|
||||
- **理由**:
|
||||
- 符合大型遷移所需的漸進路線
|
||||
- 可保留既有 URL 合約並分批改造
|
||||
- **備選方案**:
|
||||
- 一次切成全 SPA:改動面過大,回歸與 rollback 風險高
|
||||
|
||||
### Decision 3: Legacy 頁面採先包裝、後重寫策略(已確認)
|
||||
- **選擇**: `job-query`、`excel-query`、`query-tool`、`tmtt-defect` 先以 wrapper route 納入新殼層,再逐頁重寫為標準 Vue 模組。
|
||||
- **理由**:
|
||||
- 先完成抽屜/導航/樣式治理,不被單頁重寫阻塞
|
||||
- 可逐步替換,控制每次上線風險
|
||||
- **備選方案**:
|
||||
- 直接重寫四頁:會延後主幹遷移,且依賴資料邏輯盤點完整度
|
||||
|
||||
### Decision 4: Tailwind 為主樣式系統,保留過渡期雙軌
|
||||
- **選擇**: 新增 Tailwind 設計 token 與元件規範,新功能優先用 Tailwind;舊頁 CSS 分批遷移。
|
||||
- **理由**:
|
||||
- 先建立統一規範,避免繼續累積散落 CSS
|
||||
- 遷移節奏可與功能迭代對齊
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Risk] 切頁不再常駐多頁狀態,使用者感知切換較慢** → **Mitigation**: 保持頁面 bundle 切分與快取策略,後續再評估 prefetch。
|
||||
- **[Risk] 既有測試仍假設 iframe DOM 結構** → **Mitigation**: 分階段更新 template/e2e/stress 斷言為 router/navigation 契約。
|
||||
- **[Risk] 抽屜配置與路由表可能出現不一致** → **Mitigation**: 新增導航一致性檢查(缺失 route、權限錯置、排序衝突)。
|
||||
- **[Risk] Tailwind 與既有 CSS 共存期造成樣式衝突** → **Mitigation**: 設定 migration lint 規則與 page-level ownership,限制新增散落 CSS。
|
||||
- **[Risk] Legacy wrapper 週期拉長導致技術債滯留** → **Mitigation**: 在 tasks 中明確列出逐頁重寫里程碑與退出條件。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 定義抽屜-路由契約(來源、排序、權限、可見性)並建立檢查機制。
|
||||
2. 建立 SPA shell 與 Router,先接管 portal 導覽與主要報表頁入口。
|
||||
3. 移除 iframe 導覽路徑,保留舊 URL 行為與 fallback。
|
||||
4. 導入 Tailwind 設計系統並建立共用元件層。
|
||||
5. 將四個 legacy 頁面先包裝接入新殼層,再分批重寫。
|
||||
6. 完成測試與觀測遷移(模板、E2E、壓測、性能基線)。
|
||||
|
||||
## Current Baseline Snapshot (2026-02-11)
|
||||
|
||||
### Effective drawer visibility (derived from current `drawers + pages + status + admin_only`)
|
||||
|
||||
- Non-admin visible routes:
|
||||
- `reports`: `/wip-overview`, `/resource`, `/qc-gate`
|
||||
- `drawer-2`: `/resource-history`
|
||||
- `drawer`: `/job-query`
|
||||
- Admin visible routes:
|
||||
- `reports`: `/wip-overview`, `/hold-overview`, `/resource`, `/qc-gate`
|
||||
- `drawer-2`: `/hold-history`, `/resource-history`
|
||||
- `drawer`: `/job-query`
|
||||
- `dev-tools`: `/tables`, `/admin/pages`, `/excel-query`, `/admin/performance`, `/query-tool`, `/tmtt-defect`, `/mid-section-defect`
|
||||
|
||||
### Query/route contracts that must not regress
|
||||
|
||||
- `/wip-overview`: query filters `workorder`, `lotid`, `package`, `type`, `status`
|
||||
- `/wip-detail`: query filters `workcenter`, `workorder`, `lotid`, `package`, `type`, `status`
|
||||
- `/hold-detail`: required query `reason` (missing reason redirects away by current server/client guard)
|
||||
- `/resource-history`: query params built from date range, granularity, groups/families/machines, production flags
|
||||
|
||||
## Functional Parity Matrix
|
||||
|
||||
| Route / Surface | Migration Mode | Must Preserve |
|
||||
| --- | --- | --- |
|
||||
| `/` portal shell | SPA (router host) | Drawer grouping/order/visibility, health widget, auth-linked visibility |
|
||||
| `/wip-overview` | Vue route view | Filter URL sync, status filter behavior, drill-down to detail pages |
|
||||
| `/wip-detail` | Vue route view | Query-param entry, pagination/filter semantics, back-link query continuity |
|
||||
| `/hold-overview` | Vue route view | Hold type/reason filter behavior, treemap/matrix interaction |
|
||||
| `/hold-history` | Vue route view | Date/record type filter semantics, reason pareto interactions |
|
||||
| `/resource` | Vue route view | Group/status filtering and summary parity |
|
||||
| `/resource-history` | Vue route view | Query validation, summary/detail/export behavior parity |
|
||||
| `/qc-gate` | Vue route view | Chart↔table linked filtering and refresh behaviors |
|
||||
| `/job-query` | Wrapper first | Resource/date query, transaction query, CSV export |
|
||||
| `/excel-query` | Wrapper first | Upload/column detect/query/export workflow |
|
||||
| `/query-tool` | Wrapper first | Resolve/history/association/equipment-period workflow |
|
||||
| `/tmtt-defect` | Wrapper first | Date-range query and CSV export workflow |
|
||||
|
||||
## Data Contract Safety Net
|
||||
|
||||
- 建立「遷移前基線快照」:
|
||||
- drawer visibility snapshot(admin / non-admin)
|
||||
- route response smoke snapshot(HTTP status + critical payload keys)
|
||||
- critical page JSON schema snapshots(summary/detail/pagination key sets)
|
||||
- 建立「遷移後對等檢查」:
|
||||
- key presence parity(不可缺欄位)
|
||||
- type compatibility(數值/字串/陣列型別)
|
||||
- empty-state semantics(空資料行為一致)
|
||||
- 對 legacy wrapper 頁面增加 wrapper-contract 測試:
|
||||
- route reachable
|
||||
- primary query path success
|
||||
- export path reachable (where applicable)
|
||||
|
||||
## Go / No-Go Gates (Cutover)
|
||||
|
||||
- G1 Route availability:
|
||||
- P0 路由(portal + major report routes)100% 回應 2xx/3xx
|
||||
- G2 Drawer parity:
|
||||
- admin/non-admin 可見路由集合與 baseline 差異為 0
|
||||
- G3 Workflow parity:
|
||||
- parity matrix 中每頁核心流程至少 1 條 smoke path 通過率 100%
|
||||
- G4 Client stability:
|
||||
- E2E 測試中未捕獲未處理 JS runtime error(critical path)
|
||||
- G5 Data contract:
|
||||
- critical API payload key/type parity gate 全部通過
|
||||
- G6 Performance:
|
||||
- route switch latency 與 baseline 比較不得惡化超過既定閾值
|
||||
- G7 Rollback readiness:
|
||||
- rollback rehearsal 完成且時間達標(可在目標時間內恢復舊路徑)
|
||||
|
||||
## Rollback / Kill-Switch Strategy
|
||||
|
||||
1. 保留舊入口路徑與必要 fallback,直到全量 gate 通過。
|
||||
2. 使用可配置切換(feature flag / env-based toggle)控制新 shell 導航啟用。
|
||||
3. 一旦觸發回滾條件(G1/G2/G3 任一 critical fail),立即切回舊導航路徑。
|
||||
4. 回滾後保留觀測資料並建立失敗歸因報告,再進行下一輪修復與 rehearsal。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- `frame_id/tool_src` 欄位何時在資料模型層正式退場?
|
||||
- legacy wrapper 對使用者是否顯示遷移標記(例如 beta badge)?
|
||||
- 動效方案是否在第一版限定 `Vue Transition`,GSAP 延後到二階段?
|
||||
@@ -0,0 +1,64 @@
|
||||
## Why
|
||||
|
||||
目前前端處於「多入口頁 + portal iframe 切頁 + 分散 CSS」型態,已出現幾個結構性問題:頁面生命週期分裂、導覽與內容耦合、樣式規範難以統一、重用元件難以制度化。專案已大量採用 Vite + Vue 3,現在具備升級為單一 SPA Shell 的條件,應趁此時移除 iframe 並建立可持續擴充的前端架構基線。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 建立前端 SPA Shell:以 `Vite + Vue 3` 為單一入口,採 `Vue Router` 管理報表模組切換。
|
||||
- 完整移除 iframe 架構:portal 不再以 `frame_id/toolFrame` 嵌入內容,改為標準路由渲染。
|
||||
- 導入 Tailwind CSS 作為主樣式系統,建立統一設計 token、元件風格與版面規則,逐步取代現有分散 CSS。
|
||||
- 建立前端動效機制基線:以 `Vue Transition` 為預設,保留 `Motion/GSAP` 擴充通道,用於跨頁過場與重點互動。
|
||||
- 盤點可重用元件並收斂成共用 UI 層(如 Filter、Table、Card、KPI、Pagination 等),降低重複實作。
|
||||
- 明確採用 legacy 頁面過渡策略:`job-query`、`excel-query`、`query-tool`、`tmtt-defect` 先以路由包裝整合進新殼層,後續再分批重寫為標準 Vue 模組。
|
||||
- 保留既有後端 API 與權限邏輯,優先完成前端殼層與導航機制遷移,再進行頁面內部重構。
|
||||
- **BREAKING**: portal 由「iframe 同頁切頁」改為「SPA 路由切換」,舊有 frame 相關 DOM/測試契約不再成立。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `spa-shell-navigation`: 建立 Vue Router 為核心的報表導航殼層,取代 iframe 切頁機制。
|
||||
- `tailwind-design-system`: 以 Tailwind + token + 共用元件規範統一前端樣式。
|
||||
- `frontend-motion-system`: 定義頁面過場與互動動效的可維護實作策略(Vue Transition 為主,進階情境可擴充)。
|
||||
- `legacy-page-wrapper-strategy`: 定義 legacy 頁面先包裝、後重寫的過渡標準與邊界。
|
||||
|
||||
### Modified Capabilities
|
||||
- `portal-drawer-navigation`: 從 iframe frame-target 導覽改為 router-aware 導覽,維持抽屜分組與權限規則。
|
||||
- `vue-vite-page-architecture`: 從多獨立頁入口演進到 SPA shell + 路由模組化,並納入 legacy-wrapper 相容模式。
|
||||
- `full-vite-page-modularization`: 將既有共用邏輯由頁面級搬移至共用模組與設計系統層,提升重用與一致性。
|
||||
- `migration-gates-and-rollout`: 將本次搬遷的上線/回滾條件明確化為可量測 gate,避免「可部署但不可用」風險。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected frontend app structure:
|
||||
- `frontend/src/portal/*`(改為 SPA shell / router host)
|
||||
- `frontend/vite.config.js`(入口與打包策略調整)
|
||||
- `frontend/src/*` 多頁模組(路由化、共用元件化、樣式收斂)
|
||||
- Affected templates/routes:
|
||||
- `src/mes_dashboard/templates/portal.html`(iframe 區塊移除)
|
||||
- Flask route 對 SPA entry 與 fallback 行為需重新定義
|
||||
- Affected shared styling:
|
||||
- 現有 `wip-shared/resource-shared` 樣式與各頁 style.css 將分階段合併到 Tailwind 設計系統
|
||||
- Affected testing:
|
||||
- 模板整合測試、E2E、壓測需從 iframe 契約改為 router/navigation 契約
|
||||
- 需新增 route-level smoke、drawer visibility parity、legacy wrapper contract、核心 API parity gate
|
||||
- Dependency change:
|
||||
- 新增 Tailwind CSS(必要時含 PostCSS 生態)
|
||||
- 動效方案預設不新增第三方;僅在必要場景引入 GSAP 或等價方案
|
||||
- Explicit migration decision:
|
||||
- `job-query`、`excel-query`、`query-tool`、`tmtt-defect` 採「先包裝、後重寫」策略
|
||||
|
||||
## Implementation Start Protocol
|
||||
|
||||
- 實作啟動時(第一個 `/opsx:apply` session)必須先完成 baseline 產出,再進行功能改造:
|
||||
- drawer visibility baseline(admin / non-admin)
|
||||
- route + query contract baseline(P0/P1 頁面)
|
||||
- critical API payload key/type baseline
|
||||
- 在 baseline 產出並經 review 確認前,不進行 iframe 拆除與 Router 切換提交。
|
||||
- 所有切換以 gate 驅動,不以主觀感受判定可上線。
|
||||
|
||||
## Release Safety Criteria
|
||||
|
||||
- 不可發生 P0 route 無法進入或 core workflow 中斷。
|
||||
- 抽屜可見性(admin / non-admin)與 baseline 差異必須為 0。
|
||||
- 既有 URL/query 行為不可破壞(含 drill-down 與 direct-link)。
|
||||
- 必須具備可演練且可在時限內完成的回滾路徑(含 kill-switch)。
|
||||
@@ -0,0 +1,24 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Navigation transitions SHALL use a maintainable baseline motion system
|
||||
The frontend SHALL provide route and panel transition effects using a baseline motion mechanism suitable for long-term maintenance.
|
||||
|
||||
#### Scenario: Route transition feedback
|
||||
- **WHEN** a user navigates between report modules
|
||||
- **THEN** the shell SHALL provide consistent transition feedback
|
||||
- **THEN** transitions SHALL NOT block route completion or data loading
|
||||
|
||||
### Requirement: Motion behavior SHALL support reduced-motion accessibility
|
||||
The motion system SHALL respect reduced-motion user preferences.
|
||||
|
||||
#### Scenario: Reduced-motion preference
|
||||
- **WHEN** user agent indicates reduced motion preference
|
||||
- **THEN** non-essential animations SHALL be minimized or disabled
|
||||
- **THEN** primary interactions SHALL remain fully usable
|
||||
|
||||
### Requirement: Motion effects SHALL preserve functional correctness
|
||||
Animation implementation SHALL NOT alter data correctness, query timing semantics, or interaction outcomes.
|
||||
|
||||
#### Scenario: Interactive action during motion
|
||||
- **WHEN** users perform filtering, refresh, or drill-down actions during transitions
|
||||
- **THEN** resulting API calls and state updates SHALL remain functionally equivalent to non-animated execution
|
||||
@@ -0,0 +1,23 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Major Pages SHALL be Managed by Vite Modules
|
||||
The system SHALL provide Vite-managed module entries for major portal pages under a phased SPA-shell migration while keeping direct route access compatible.
|
||||
|
||||
#### Scenario: Portal shell module loading
|
||||
- **WHEN** the portal experience is rendered
|
||||
- **THEN** shell behavior MUST load from Vite-built module assets when available
|
||||
|
||||
#### Scenario: Module fallback continuity
|
||||
- **WHEN** a required Vite asset is unavailable in a migration phase
|
||||
- **THEN** the system MUST keep affected page behavior functional through explicit fallback logic
|
||||
|
||||
### Requirement: Modularization MUST Preserve Established Navigation and Drill-Down Semantics
|
||||
Refactoring into Vite modules and SPA shell routing SHALL not alter existing route paths, query semantics, and drill-down entry points.
|
||||
|
||||
#### Scenario: User follows existing drill-down path
|
||||
- **WHEN** the user navigates from summary page to detail views
|
||||
- **THEN** the resulting flow and parameter semantics MUST match the established baseline behavior
|
||||
|
||||
#### Scenario: Direct detail route remains valid
|
||||
- **WHEN** users open existing detail routes directly with query parameters (e.g., `/wip-detail?workcenter=...`, `/hold-detail?reason=...`)
|
||||
- **THEN** route-level behavior MUST remain compatible with established baseline expectations
|
||||
@@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Selected legacy pages SHALL be integrated via wrapper-first strategy
|
||||
The migration SHALL integrate `job-query`, `excel-query`, `query-tool`, and `tmtt-defect` through wrapper-based routing before full rewrites.
|
||||
|
||||
#### Scenario: Wrapper route availability for selected pages
|
||||
- **WHEN** users navigate to each selected legacy page from the new shell
|
||||
- **THEN** the route SHALL remain reachable and functionally usable through the wrapper layer
|
||||
|
||||
### Requirement: Wrapper mode SHALL preserve legacy functional parity
|
||||
Wrapper integration SHALL preserve current API interactions, core user workflows, and error handling semantics for wrapped pages.
|
||||
|
||||
#### Scenario: Legacy workflow parity under wrapper
|
||||
- **WHEN** users execute core operations on a wrapped page (query/filter/export where applicable)
|
||||
- **THEN** operation results SHALL remain behaviorally equivalent to pre-wrapper baseline
|
||||
|
||||
### Requirement: Wrapper phase SHALL define rewrite exit criteria
|
||||
Each wrapped page SHALL have explicit readiness criteria that gate transition from wrapper mode to full Vue module rewrite.
|
||||
|
||||
#### Scenario: Rewrite readiness decision
|
||||
- **WHEN** a wrapped page reaches agreed quality and parity thresholds
|
||||
- **THEN** the page SHALL be eligible for rewrite scheduling
|
||||
- **THEN** wrapper decommission SHALL only occur after rewrite parity validation passes
|
||||
@@ -0,0 +1,23 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Migration Gates SHALL Define Cutover Readiness
|
||||
The system SHALL define explicit migration gates for functional parity, build integrity, drawer visibility parity, and operational health before final cutover.
|
||||
|
||||
#### Scenario: Gate evaluation before cutover
|
||||
- **WHEN** release is prepared for final cutover
|
||||
- **THEN** all required migration gates MUST pass or cutover SHALL be blocked
|
||||
|
||||
#### Scenario: Functional parity gate fails
|
||||
- **WHEN** any critical route or core workflow parity check fails during gate execution
|
||||
- **THEN** release governance MUST treat the cutover as failed and prevent promotion
|
||||
|
||||
### Requirement: Rollout and Rollback Procedures MUST be Actionable
|
||||
The system SHALL document actionable rollout and rollback procedures for SPA-shell migration and iframe decommission.
|
||||
|
||||
#### Scenario: Rollback execution
|
||||
- **WHEN** post-cutover validation fails critical checks
|
||||
- **THEN** operators MUST be able to execute documented rollback steps to restore previous stable behavior
|
||||
|
||||
#### Scenario: Kill-switch rollback
|
||||
- **WHEN** severe production regression is detected after cutover
|
||||
- **THEN** operators MUST be able to disable the new navigation path through a documented kill-switch mechanism and recover service usability within the defined rollback target time
|
||||
@@ -0,0 +1,47 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Portal Navigation SHALL Group Entries by Functional Drawers
|
||||
The portal SHALL group navigation entries into functional drawers as defined in the `drawers` configuration of `page_status.json`, rendered by the active portal runtime (server template or SPA shell) without changing drawer assignment semantics.
|
||||
|
||||
#### Scenario: Drawer grouping visibility
|
||||
- **WHEN** users open the portal
|
||||
- **THEN** the sidebar SHALL display drawers in the order defined by each drawer's `order` field
|
||||
- **THEN** each drawer SHALL show only the pages assigned to it via `drawer_id`, sorted by each page's `order` field
|
||||
|
||||
#### Scenario: Admin-only drawer visibility
|
||||
- **WHEN** a drawer has `admin_only: true` and the current user is not admin
|
||||
- **THEN** the drawer and all its pages SHALL NOT be rendered in the sidebar
|
||||
|
||||
#### Scenario: Empty drawer visibility
|
||||
- **WHEN** a drawer has no visible pages (all filtered out by page visibility checks)
|
||||
- **THEN** the drawer group title SHALL NOT be rendered
|
||||
|
||||
### Requirement: Existing Page Behavior SHALL Remain Compatible
|
||||
The portal navigation refactor SHALL preserve existing target routes while replacing iframe-based page embedding with route-driven navigation.
|
||||
|
||||
#### Scenario: Route continuity
|
||||
- **WHEN** a user selects an existing page entry from a drawer
|
||||
- **THEN** the corresponding original route SHALL be loaded without changing page business logic behavior
|
||||
|
||||
#### Scenario: Direct navigation without iframe
|
||||
- **WHEN** a sidebar item is clicked
|
||||
- **THEN** the browser SHALL navigate to the page's route in the same window
|
||||
- **THEN** the portal SHALL NOT render or activate iframe elements for page content
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Drawer Configuration and Visibility SHALL Remain Deterministic During Migration
|
||||
Migration to SPA navigation SHALL preserve the effective drawer visibility outcomes defined by current `drawers + pages + status + admin_only` rules.
|
||||
|
||||
#### Scenario: Non-admin visible drawer pages remain stable
|
||||
- **WHEN** a non-admin user opens the portal after migration
|
||||
- **THEN** only pages with released visibility in non-admin drawers SHALL be visible
|
||||
- **THEN** admin-only drawers SHALL remain hidden
|
||||
|
||||
#### Scenario: Admin visible drawer pages remain stable
|
||||
- **WHEN** an admin user opens the portal after migration
|
||||
- **THEN** all pages allowed by drawer assignment and page status rules SHALL remain visible
|
||||
|
||||
#### Scenario: Duplicate order values resolve deterministically
|
||||
- **WHEN** multiple pages or drawers share the same `order` value
|
||||
- **THEN** rendering order SHALL still be deterministic and repeatable across requests
|
||||
@@ -0,0 +1,28 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Portal SHALL provide a SPA shell driven by Vue Router
|
||||
The portal frontend SHALL use a single SPA shell entry and Vue Router to render page modules without iframe embedding.
|
||||
|
||||
#### Scenario: Drawer navigation renders router view
|
||||
- **WHEN** a user clicks a sidebar page entry
|
||||
- **THEN** the active route SHALL be updated through Vue Router
|
||||
- **THEN** the main content area SHALL render the corresponding route view without iframe usage
|
||||
|
||||
### Requirement: Existing route contracts SHALL remain stable in SPA mode
|
||||
Migration to SPA shell SHALL preserve existing route paths and deep-link behavior.
|
||||
|
||||
#### Scenario: Direct route entry remains functional
|
||||
- **WHEN** a user opens an existing route directly (bookmark or refresh)
|
||||
- **THEN** the route SHALL resolve to the same page functionality as before migration
|
||||
- **THEN** required query parameters SHALL continue to be interpreted with compatible semantics
|
||||
|
||||
### Requirement: SPA shell navigation SHALL enforce page visibility rules
|
||||
SPA navigation SHALL respect backend-defined drawer and page visibility outcomes.
|
||||
|
||||
#### Scenario: Non-admin visibility in SPA shell
|
||||
- **WHEN** a non-admin user opens the shell
|
||||
- **THEN** routes and drawer items restricted to admin-only visibility SHALL NOT be presented as navigable entries
|
||||
|
||||
#### Scenario: Admin visibility in SPA shell
|
||||
- **WHEN** an admin user opens the shell
|
||||
- **THEN** pages allowed by drawer and page status rules SHALL be presented as navigable entries
|
||||
@@ -0,0 +1,25 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Frontend styles SHALL be governed by Tailwind design tokens
|
||||
The frontend SHALL define a Tailwind-based design token system for color, spacing, typography, radius, and elevation to ensure consistent styling across modules.
|
||||
|
||||
#### Scenario: Shared token usage across modules
|
||||
- **WHEN** two report modules render equivalent UI elements (e.g., card, filter chip, primary button)
|
||||
- **THEN** they SHALL use the same token-backed style semantics
|
||||
- **THEN** visual output SHALL remain consistent across modules
|
||||
|
||||
### Requirement: Tailwind migration SHALL support coexistence with legacy CSS
|
||||
The migration SHALL allow Tailwind and existing page CSS to coexist during phased rollout without breaking existing pages.
|
||||
|
||||
#### Scenario: Legacy page remains functional during coexistence
|
||||
- **WHEN** a not-yet-migrated page is rendered
|
||||
- **THEN** existing CSS behavior SHALL remain intact
|
||||
- **THEN** Tailwind introduction SHALL NOT cause blocking style regressions
|
||||
|
||||
### Requirement: New shared UI components SHALL prefer Tailwind-first styling
|
||||
Newly introduced shared components SHALL be implemented with Tailwind-first conventions to avoid expanding duplicated page-local CSS.
|
||||
|
||||
#### Scenario: Shared component adoption
|
||||
- **WHEN** a new shared component is introduced in migration scope
|
||||
- **THEN** its primary style contract SHALL be expressed through Tailwind utilities/components
|
||||
- **THEN** page-local CSS additions SHALL be minimized and justified
|
||||
@@ -0,0 +1,19 @@
|
||||
## MODIFIED 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 as top-level navigation target
|
||||
- **WHEN** a pure Vite page is opened from portal direct navigation
|
||||
- **THEN** the page SHALL render correctly as a top-level route without iframe embedding dependency
|
||||
- **THEN** page functionality SHALL NOT rely on portal-managed frame lifecycle
|
||||
|
||||
#### Scenario: Direct URL with query parameters remains valid
|
||||
- **WHEN** users directly open a pure Vite route with existing query parameters (e.g., `/wip-detail?workcenter=...`)
|
||||
- **THEN** the page SHALL preserve existing parameter semantics and load behavior
|
||||
- **THEN** SPA shell integration SHALL NOT break direct route entry
|
||||
@@ -0,0 +1,97 @@
|
||||
## 0. Implementation Kickoff (Apply Session Day-1)
|
||||
|
||||
- [x] 0.1 Generate and commit migration baseline snapshots (drawer visibility, route/query contracts, critical API payload key/type).
|
||||
- [x] 0.2 Create parity checklist artifacts mapped to the functional parity matrix routes.
|
||||
- [x] 0.3 Define and verify cutover control mechanism (feature flag / env toggle) before any breaking navigation change.
|
||||
- [x] 0.4 Record rollback rehearsal plan with target recovery SLO and responsible operator steps.
|
||||
|
||||
## 1. Drawer Baseline and Governance Contract
|
||||
|
||||
- [x] 1.1 Capture the current production drawer baseline from `data/page_status.json` (id/name/order/admin_only/pages) as migration reference data.
|
||||
- [x] 1.2 Define canonical drawer responsibilities: IA grouping, ordering, and permission visibility only (no iframe/frame loading semantics).
|
||||
- [x] 1.3 Define a drawer-route consistency contract (route exists, drawer exists, order is valid, admin_only behavior is deterministic).
|
||||
- [x] 1.4 Add validation checks/tests for admin and non-admin drawer visibility against the current baseline configuration.
|
||||
- [x] 1.5 Define `frame_id/tool_src` deprecation policy and transition checkpoints.
|
||||
|
||||
## 2. SPA Shell and Router Foundation
|
||||
|
||||
- [x] 2.1 Create a SPA shell entry for portal navigation using Vue 3 + Vue Router.
|
||||
- [x] 2.2 Build router records from drawer/page route contracts while preserving existing URL compatibility.
|
||||
- [x] 2.3 Implement router-aware sidebar active state and breadcrumb/title metadata handling.
|
||||
- [x] 2.4 Align auth/permission checks between backend route guard and frontend navigation guard behavior.
|
||||
- [x] 2.5 Keep health-status widget behavior available in shell without iframe coupling.
|
||||
|
||||
## 3. Portal Iframe Decommission
|
||||
|
||||
- [x] 3.1 Refactor `portal.html` to remove iframe panel DOM and switch sidebar metadata to route-driven navigation.
|
||||
- [x] 3.2 Refactor `frontend/src/portal/main.js` to remove frame activation/lazy-load/unload logic.
|
||||
- [x] 3.3 Replace iframe-specific UI states with route-transition states and loading indicators.
|
||||
- [x] 3.4 Remove portal CSS rules that target iframe layout while preserving current visual structure.
|
||||
- [x] 3.5 Verify non-admin/admin navigation outcomes for all current drawers under direct routing.
|
||||
|
||||
## 4. Tailwind Design System Bootstrap
|
||||
|
||||
- [x] 4.1 Introduce Tailwind CSS + PostCSS configuration into the frontend build pipeline.
|
||||
- [x] 4.2 Define design tokens (color, spacing, typography, radius, elevation, z-index) mapped to existing UI language.
|
||||
- [x] 4.3 Establish global base/component/utility layers and migration-safe style ordering.
|
||||
- [x] 4.4 Define style governance rules to prevent new large page-local CSS during migration.
|
||||
- [x] 4.5 Publish migration guide for converting existing CSS modules/pages to Tailwind patterns.
|
||||
|
||||
## 5. Shared UI and Composable Consolidation
|
||||
|
||||
- [x] 5.1 Inventory duplicated UI patterns across WIP/Resource/Hold/QC pages (filter bar, KPI cards, tables, pagination, badges, banners).
|
||||
- [x] 5.2 Create shared UI component layer and normalize props/events/slot contracts.
|
||||
- [x] 5.3 Consolidate cross-page composables (auto-refresh, autocomplete, query state, pagination state) under shared modules.
|
||||
- [x] 5.4 Migrate existing pages to shared components incrementally with visual parity checks.
|
||||
- [x] 5.5 Remove obsolete duplicated component/style artifacts after each migration batch.
|
||||
|
||||
## 6. Legacy Page Wrapper Phase (Confirmed Decision)
|
||||
|
||||
- [x] 6.1 Implement wrapper integration for `job-query` inside the new router/shell flow.
|
||||
- [x] 6.2 Implement wrapper integration for `excel-query` inside the new router/shell flow.
|
||||
- [x] 6.3 Implement wrapper integration for `query-tool` inside the new router/shell flow.
|
||||
- [x] 6.4 Implement wrapper integration for `tmtt-defect` inside the new router/shell flow.
|
||||
- [x] 6.5 Define wrapper-level telemetry (load success, error, latency) and fallback behavior.
|
||||
- [x] 6.6 Document hard exit criteria that determine when each wrapped page can be considered rewrite-ready.
|
||||
|
||||
## 7. Legacy Rewrite Execution (Post-Wrapper)
|
||||
|
||||
- Reference checklist: `docs/migration/portal-no-iframe/legacy_rewrite_smoke_checklists.md`
|
||||
- Reference exemplar: `docs/migration/portal-no-iframe/tmtt_rewrite_exemplar.md`
|
||||
- Reference playbook: `docs/migration/portal-no-iframe/legacy_rewrite_playbook.md`
|
||||
- Reference decommission record: `docs/migration/portal-no-iframe/wrapper_decommission_report.md`
|
||||
- [x] 7.1 Prioritize rewrite order among wrapped pages using usage/complexity/risk scoring.
|
||||
- [x] 7.2 Rewrite first legacy page as canonical migration exemplar with shared UI + Tailwind.
|
||||
- [x] 7.3 Rewrite remaining three legacy pages with reusable migration playbook and acceptance criteria.
|
||||
- [x] 7.4 Decommission wrappers after rewrite completion and parity validation.
|
||||
|
||||
## 8. Interaction and Motion System
|
||||
|
||||
- [x] 8.1 Define baseline motion guidelines using Vue Transition (route transitions, panel changes, loading states).
|
||||
- [x] 8.2 Implement reduced-motion accessibility behavior and fallback styles.
|
||||
- [x] 8.3 Add key interaction transitions for filter apply, chart/table refresh, and drawer navigation.
|
||||
- [x] 8.4 Define an escalation rule for when GSAP (or equivalent) is allowed beyond baseline transitions.
|
||||
|
||||
## 9. Testing, Quality Gates, and Performance
|
||||
|
||||
- [x] 9.1 Update unit/template tests from iframe assumptions to router/drawer contract assertions.
|
||||
- [x] 9.2 Update E2E and stress suites to validate route navigation stability instead of iframe switching.
|
||||
- [x] 9.3 Add regression tests for drawer ordering, admin_only filtering, and mixed release/dev visibility.
|
||||
- [x] 9.4 Add contract tests for legacy wrapper routing and fallback behavior.
|
||||
- [x] 9.5 Establish performance baselines (first paint, route switch latency, memory footprint) and compare pre/post migration.
|
||||
|
||||
## 10. Rollout, Cleanup, and Spec Closure
|
||||
|
||||
- [x] 10.1 Define phased rollout plan with canary scope and success/error thresholds.
|
||||
- [x] 10.2 Define rollback strategy for shell/router cutover and wrapper failures.
|
||||
- [x] 10.3 Remove `frame_id/tool_src` from runtime navigation payload after wrapper-to-rewrite milestones are complete.
|
||||
- [x] 10.4 Sync changed requirements into main specs and prepare archive criteria for this migration change.
|
||||
|
||||
## 11. Cutover Gate Enforcement (Measurable)
|
||||
|
||||
- [x] 11.1 Enforce G1 route availability gate: P0 routes return 2xx/3xx at 100% pass rate in release validation.
|
||||
- [x] 11.2 Enforce G2 drawer parity gate: admin/non-admin visible route sets must match pre-migration baseline exactly (delta = 0).
|
||||
- [x] 11.3 Enforce G3 workflow parity gate: one critical smoke flow per route in parity matrix must pass at 100%.
|
||||
- [x] 11.4 Enforce G4 client stability gate: zero unhandled JavaScript runtime errors on critical E2E paths.
|
||||
- [x] 11.5 Enforce G5 data contract gate: required payload key/type parity checks must pass for all critical APIs.
|
||||
- [x] 11.6 Enforce G7 rollback readiness gate: rollback rehearsal must recover stable navigation within target SLO (e.g., <= 15 minutes).
|
||||
27
openspec/specs/frontend-motion-system/spec.md
Normal file
27
openspec/specs/frontend-motion-system/spec.md
Normal file
@@ -0,0 +1,27 @@
|
||||
## Purpose
|
||||
Define stable requirements for frontend-motion-system.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Navigation transitions SHALL use a maintainable baseline motion system
|
||||
The frontend SHALL provide route and panel transition effects using a baseline motion mechanism suitable for long-term maintenance.
|
||||
|
||||
#### Scenario: Route transition feedback
|
||||
- **WHEN** a user navigates between report modules
|
||||
- **THEN** the shell SHALL provide consistent transition feedback
|
||||
- **THEN** transitions SHALL NOT block route completion or data loading
|
||||
|
||||
### Requirement: Motion behavior SHALL support reduced-motion accessibility
|
||||
The motion system SHALL respect reduced-motion user preferences.
|
||||
|
||||
#### Scenario: Reduced-motion preference
|
||||
- **WHEN** user agent indicates reduced motion preference
|
||||
- **THEN** non-essential animations SHALL be minimized or disabled
|
||||
- **THEN** primary interactions SHALL remain fully usable
|
||||
|
||||
### Requirement: Motion effects SHALL preserve functional correctness
|
||||
Animation implementation SHALL NOT alter data correctness, query timing semantics, or interaction outcomes.
|
||||
|
||||
#### Scenario: Interactive action during motion
|
||||
- **WHEN** users perform filtering, refresh, or drill-down actions during transitions
|
||||
- **THEN** resulting API calls and state updates SHALL remain functionally equivalent to non-animated execution
|
||||
@@ -2,15 +2,15 @@
|
||||
Define stable requirements for full-vite-page-modularization.
|
||||
## Requirements
|
||||
### Requirement: Major Pages SHALL be Managed by Vite Modules
|
||||
The system SHALL provide Vite-managed module entries for major portal pages, replacing inline scripts in a phased manner.
|
||||
The system SHALL provide Vite-managed module entries for major portal pages under a phased SPA-shell migration while keeping direct route access compatible.
|
||||
|
||||
#### Scenario: Portal module loading
|
||||
- **WHEN** the portal page is rendered
|
||||
#### Scenario: Portal shell module loading
|
||||
- **WHEN** the portal experience is rendered
|
||||
- **THEN** it MUST load its behavior from a Vite-built module asset when available
|
||||
|
||||
#### Scenario: Page module fallback
|
||||
#### Scenario: Module fallback continuity
|
||||
- **WHEN** a required Vite asset is unavailable
|
||||
- **THEN** the system MUST keep page behavior functional through explicit fallback logic
|
||||
- **THEN** the system MUST keep affected page behavior functional through explicit fallback logic
|
||||
|
||||
### Requirement: Build Pipeline SHALL Produce Backend-Served Assets
|
||||
Vite build output MUST be emitted into backend static paths and served by Flask/Gunicorn on the same origin.
|
||||
@@ -27,12 +27,16 @@ Page entry modules MUST consume shared chart/query/drawer utilities for common b
|
||||
- **THEN** the behavior MUST be provided by shared Vite modules rather than duplicated page-local implementations
|
||||
|
||||
### Requirement: Modularization MUST Preserve Established Navigation and Drill-Down Semantics
|
||||
Refactoring into Vite modules SHALL not alter existing page transitions, independent tabs, and drill-down entry points.
|
||||
Refactoring into Vite modules and SPA shell routing SHALL not alter existing route paths, query semantics, and drill-down entry points.
|
||||
|
||||
#### Scenario: User follows existing drill-down path
|
||||
- **WHEN** the user navigates from summary page to detail views
|
||||
- **THEN** the resulting flow and parameter semantics MUST match the established baseline behavior
|
||||
|
||||
#### Scenario: Direct detail route remains valid
|
||||
- **WHEN** users open existing detail routes directly with query parameters (e.g., `/wip-detail?workcenter=...`, `/hold-detail?reason=...`)
|
||||
- **THEN** route-level behavior MUST remain compatible with established baseline expectations
|
||||
|
||||
### Requirement: Module Boundaries SHALL Support Frontend Compute Expansion
|
||||
Vite module structure MUST keep compute logic decoupled from DOM wiring so additional backend-to-frontend computation shifts can be added safely.
|
||||
|
||||
|
||||
26
openspec/specs/legacy-page-wrapper-strategy/spec.md
Normal file
26
openspec/specs/legacy-page-wrapper-strategy/spec.md
Normal file
@@ -0,0 +1,26 @@
|
||||
## Purpose
|
||||
Define stable requirements for legacy-page-wrapper-strategy.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Selected legacy pages SHALL be integrated via wrapper-first strategy
|
||||
The migration SHALL integrate `job-query`, `excel-query`, `query-tool`, and `tmtt-defect` through wrapper-based routing before full rewrites.
|
||||
|
||||
#### Scenario: Wrapper route availability for selected pages
|
||||
- **WHEN** users navigate to each selected legacy page from the new shell
|
||||
- **THEN** the route SHALL remain reachable and functionally usable through the wrapper layer
|
||||
|
||||
### Requirement: Wrapper mode SHALL preserve legacy functional parity
|
||||
Wrapper integration SHALL preserve current API interactions, core user workflows, and error handling semantics for wrapped pages.
|
||||
|
||||
#### Scenario: Legacy workflow parity under wrapper
|
||||
- **WHEN** users execute core operations on a wrapped page (query/filter/export where applicable)
|
||||
- **THEN** operation results SHALL remain behaviorally equivalent to pre-wrapper baseline
|
||||
|
||||
### Requirement: Wrapper phase SHALL define rewrite exit criteria
|
||||
Each wrapped page SHALL have explicit readiness criteria that gate transition from wrapper mode to full Vue module rewrite.
|
||||
|
||||
#### Scenario: Rewrite readiness decision
|
||||
- **WHEN** a wrapped page reaches agreed quality and parity thresholds
|
||||
- **THEN** the page SHALL be eligible for rewrite scheduling
|
||||
- **THEN** wrapper decommission SHALL only occur after rewrite parity validation passes
|
||||
@@ -2,19 +2,27 @@
|
||||
Define stable requirements for migration-gates-and-rollout.
|
||||
## Requirements
|
||||
### Requirement: Migration Gates SHALL Define Cutover Readiness
|
||||
The system SHALL define explicit migration gates for functional parity, build integrity, and operational health before final cutover.
|
||||
The system SHALL define explicit migration gates for functional parity, build integrity, drawer visibility parity, and operational health before final cutover.
|
||||
|
||||
#### Scenario: Gate evaluation before cutover
|
||||
- **WHEN** release is prepared for final cutover
|
||||
- **THEN** all required migration gates MUST pass or cutover SHALL be blocked
|
||||
|
||||
#### Scenario: Functional parity gate fails
|
||||
- **WHEN** any critical route or core workflow parity check fails during gate execution
|
||||
- **THEN** release governance MUST treat the cutover as failed and prevent promotion
|
||||
|
||||
### Requirement: Rollout and Rollback Procedures MUST be Actionable
|
||||
The system SHALL document actionable rollout and rollback procedures for root migration.
|
||||
The system SHALL document actionable rollout and rollback procedures for SPA-shell migration and iframe decommission.
|
||||
|
||||
#### Scenario: Rollback execution
|
||||
- **WHEN** post-cutover validation fails critical checks
|
||||
- **THEN** operators MUST be able to execute documented rollback steps to restore previous stable behavior
|
||||
|
||||
#### Scenario: Kill-switch rollback
|
||||
- **WHEN** severe production regression is detected after cutover
|
||||
- **THEN** operators MUST be able to disable the new navigation path through a documented kill-switch mechanism and recover service usability within the defined rollback target time
|
||||
|
||||
### Requirement: Migration Gates SHALL Include Runtime Resilience Validation
|
||||
Cutover readiness gates MUST include resilience checks for pool exhaustion handling, circuit-breaker fail-fast behavior, and recovery flow.
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ Define stable requirements for portal-drawer-navigation.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
### Requirement: Portal Navigation SHALL Group Entries by Functional Drawers
|
||||
The portal SHALL group navigation entries into functional drawers as defined in the `drawers` configuration of `page_status.json`, rendered dynamically via Jinja2 loop instead of hardcoded HTML.
|
||||
The portal SHALL group navigation entries into functional drawers as defined in the `drawers` configuration of `page_status.json`, rendered by the active portal runtime (server template or SPA shell) without changing drawer assignment semantics.
|
||||
|
||||
#### Scenario: Drawer grouping visibility
|
||||
- **WHEN** users open the portal
|
||||
@@ -17,29 +16,33 @@ The portal SHALL group navigation entries into functional drawers as defined in
|
||||
- **THEN** the drawer and all its pages SHALL NOT be rendered in the sidebar
|
||||
|
||||
#### Scenario: Empty drawer visibility
|
||||
- **WHEN** a drawer has no visible pages (all filtered out by `can_view_page()`)
|
||||
- **WHEN** a drawer has no visible pages (all filtered out by page visibility checks)
|
||||
- **THEN** the drawer group title SHALL NOT be rendered
|
||||
|
||||
### Requirement: Existing Page Behavior SHALL Remain Compatible
|
||||
The portal navigation refactor SHALL preserve existing target routes and lazy-load behavior for content frames.
|
||||
The portal navigation refactor SHALL preserve existing target routes while replacing iframe-based page embedding with route-driven navigation.
|
||||
|
||||
#### Scenario: Route continuity
|
||||
- **WHEN** a user selects an existing page entry from a dynamically rendered drawer
|
||||
- **WHEN** a user selects an existing page entry from a drawer
|
||||
- **THEN** the corresponding original route SHALL be loaded without changing page business logic behavior
|
||||
|
||||
#### Scenario: Iframe lazy-load continuity
|
||||
- **WHEN** a sidebar item is clicked for the first time
|
||||
- **THEN** the iframe SHALL lazy-load its content from the page's route, consistent with current behavior
|
||||
#### Scenario: Direct navigation without iframe
|
||||
- **WHEN** a sidebar item is clicked
|
||||
- **THEN** the browser SHALL navigate to the page's route in the same window
|
||||
- **THEN** the portal SHALL NOT render or activate iframe elements for page content
|
||||
|
||||
### Requirement: First-run migration SHALL populate drawer configuration automatically
|
||||
When `page_status.json` does not contain a `drawers` field, the system SHALL automatically create the default drawer structure matching the current hardcoded layout and assign existing pages to their corresponding drawers.
|
||||
### Requirement: Drawer Configuration and Visibility SHALL Remain Deterministic During Migration
|
||||
Migration to SPA navigation SHALL preserve the effective drawer visibility outcomes defined by current `drawers + pages + status + admin_only` rules.
|
||||
|
||||
#### Scenario: First startup after deployment
|
||||
- **WHEN** the application starts and `page_status.json` has no `drawers` field
|
||||
- **THEN** the system SHALL create three default drawers (報表類, 查詢類, 開發工具)
|
||||
- **THEN** the system SHALL assign each existing page to its historically correct drawer
|
||||
- **THEN** the system SHALL persist the updated configuration immediately
|
||||
#### Scenario: Non-admin visible drawer pages remain stable
|
||||
- **WHEN** a non-admin user opens the portal after migration
|
||||
- **THEN** only pages with released visibility in non-admin drawers SHALL be visible
|
||||
- **THEN** admin-only drawers SHALL remain hidden
|
||||
|
||||
#### Scenario: Subsequent startup
|
||||
- **WHEN** the application starts and `page_status.json` already contains a `drawers` field
|
||||
- **THEN** the system SHALL NOT modify the existing drawer configuration
|
||||
#### Scenario: Admin visible drawer pages remain stable
|
||||
- **WHEN** an admin user opens the portal after migration
|
||||
- **THEN** all pages allowed by drawer assignment and page status rules SHALL remain visible
|
||||
|
||||
#### Scenario: Duplicate order values resolve deterministically
|
||||
- **WHEN** multiple pages or drawers share the same `order` value
|
||||
- **THEN** rendering order SHALL still be deterministic and repeatable across requests
|
||||
|
||||
31
openspec/specs/spa-shell-navigation/spec.md
Normal file
31
openspec/specs/spa-shell-navigation/spec.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## Purpose
|
||||
Define stable requirements for spa-shell-navigation.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Portal SHALL provide a SPA shell driven by Vue Router
|
||||
The portal frontend SHALL use a single SPA shell entry and Vue Router to render page modules without iframe embedding.
|
||||
|
||||
#### Scenario: Drawer navigation renders router view
|
||||
- **WHEN** a user clicks a sidebar page entry
|
||||
- **THEN** the active route SHALL be updated through Vue Router
|
||||
- **THEN** the main content area SHALL render the corresponding route view without iframe usage
|
||||
|
||||
### Requirement: Existing route contracts SHALL remain stable in SPA mode
|
||||
Migration to SPA shell SHALL preserve existing route paths and deep-link behavior.
|
||||
|
||||
#### Scenario: Direct route entry remains functional
|
||||
- **WHEN** a user opens an existing route directly (bookmark or refresh)
|
||||
- **THEN** the route SHALL resolve to the same page functionality as before migration
|
||||
- **THEN** required query parameters SHALL continue to be interpreted with compatible semantics
|
||||
|
||||
### Requirement: SPA shell navigation SHALL enforce page visibility rules
|
||||
SPA navigation SHALL respect backend-defined drawer and page visibility outcomes.
|
||||
|
||||
#### Scenario: Non-admin visibility in SPA shell
|
||||
- **WHEN** a non-admin user opens the shell
|
||||
- **THEN** routes and drawer items restricted to admin-only visibility SHALL NOT be presented as navigable entries
|
||||
|
||||
#### Scenario: Admin visibility in SPA shell
|
||||
- **WHEN** an admin user opens the shell
|
||||
- **THEN** pages allowed by drawer and page status rules SHALL be presented as navigable entries
|
||||
28
openspec/specs/tailwind-design-system/spec.md
Normal file
28
openspec/specs/tailwind-design-system/spec.md
Normal file
@@ -0,0 +1,28 @@
|
||||
## Purpose
|
||||
Define stable requirements for tailwind-design-system.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Frontend styles SHALL be governed by Tailwind design tokens
|
||||
The frontend SHALL define a Tailwind-based design token system for color, spacing, typography, radius, and elevation to ensure consistent styling across modules.
|
||||
|
||||
#### Scenario: Shared token usage across modules
|
||||
- **WHEN** two report modules render equivalent UI elements (e.g., card, filter chip, primary button)
|
||||
- **THEN** they SHALL use the same token-backed style semantics
|
||||
- **THEN** visual output SHALL remain consistent across modules
|
||||
|
||||
### Requirement: Tailwind migration SHALL support coexistence with legacy CSS
|
||||
The migration SHALL allow Tailwind and existing page CSS to coexist during phased rollout without breaking existing pages.
|
||||
|
||||
#### Scenario: Legacy page remains functional during coexistence
|
||||
- **WHEN** a not-yet-migrated page is rendered
|
||||
- **THEN** existing CSS behavior SHALL remain intact
|
||||
- **THEN** Tailwind introduction SHALL NOT cause blocking style regressions
|
||||
|
||||
### Requirement: New shared UI components SHALL prefer Tailwind-first styling
|
||||
Newly introduced shared components SHALL be implemented with Tailwind-first conventions to avoid expanding duplicated page-local CSS.
|
||||
|
||||
#### Scenario: Shared component adoption
|
||||
- **WHEN** a new shared component is introduced in migration scope
|
||||
- **THEN** its primary style contract SHALL be expressed through Tailwind utilities/components
|
||||
- **THEN** page-local CSS additions SHALL be minimized and justified
|
||||
@@ -12,10 +12,10 @@ The system SHALL support serving Vite-built HTML pages directly via Flask withou
|
||||
- **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
|
||||
#### Scenario: Page works as top-level navigation target
|
||||
- **WHEN** a pure Vite page is opened from portal direct navigation
|
||||
- **THEN** the page SHALL render correctly as a top-level route without iframe embedding dependency
|
||||
- **THEN** page functionality SHALL NOT rely on portal-managed frame lifecycle
|
||||
|
||||
### 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.
|
||||
|
||||
111
scripts/generate_portal_migration_baseline.py
Executable file
111
scripts/generate_portal_migration_baseline.py
Executable file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate baseline snapshots for portal no-iframe migration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from mes_dashboard.services.navigation_contract import (
|
||||
compute_drawer_visibility,
|
||||
validate_drawer_page_contract,
|
||||
)
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PAGE_STATUS_FILE = ROOT / "data" / "page_status.json"
|
||||
OUT_DIR = ROOT / "docs" / "migration" / "portal-no-iframe"
|
||||
|
||||
|
||||
ROUTE_QUERY_CONTRACTS = {
|
||||
"/wip-overview": {
|
||||
"query_keys": ["workorder", "lotid", "package", "type", "status"],
|
||||
"notes": "filters + status URL state must remain compatible",
|
||||
},
|
||||
"/wip-detail": {
|
||||
"query_keys": ["workcenter", "workorder", "lotid", "package", "type", "status"],
|
||||
"notes": "workcenter deep-link and back-link query continuity",
|
||||
},
|
||||
"/hold-detail": {
|
||||
"query_keys": ["reason"],
|
||||
"notes": "reason required for normal access flow",
|
||||
},
|
||||
"/resource-history": {
|
||||
"query_keys": [
|
||||
"start_date",
|
||||
"end_date",
|
||||
"granularity",
|
||||
"workcenter_groups",
|
||||
"families",
|
||||
"resource_ids",
|
||||
"is_production",
|
||||
"is_key",
|
||||
"is_monitor",
|
||||
],
|
||||
"notes": "query/export params must remain compatible",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
CRITICAL_API_PAYLOAD_CONTRACTS = {
|
||||
"/api/wip/overview/summary": {
|
||||
"required_keys": ["dataUpdateDate", "runLots", "queueLots", "holdLots"],
|
||||
"notes": "summary header and cards depend on these fields",
|
||||
},
|
||||
"/api/wip/overview/matrix": {
|
||||
"required_keys": ["workcenters", "packages", "matrix", "workcenter_totals"],
|
||||
"notes": "matrix table rendering contract",
|
||||
},
|
||||
"/api/wip/hold-detail/summary": {
|
||||
"required_keys": ["workcenterCount", "packageCount", "lotCount"],
|
||||
"notes": "hold detail summary cards contract",
|
||||
},
|
||||
"/api/resource/history/summary": {
|
||||
"required_keys": ["kpi", "trend", "heatmap", "workcenter_comparison"],
|
||||
"notes": "resource history chart summary contract",
|
||||
},
|
||||
"/api/resource/history/detail": {
|
||||
"required_keys": ["data"],
|
||||
"notes": "detail table contract (plus truncated/max_records metadata when present)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_json(path: Path, payload: dict) -> None:
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
raw = json.loads(PAGE_STATUS_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
visibility = {
|
||||
"source": str(PAGE_STATUS_FILE.relative_to(ROOT)),
|
||||
"admin": compute_drawer_visibility(raw, is_admin=True),
|
||||
"non_admin": compute_drawer_visibility(raw, is_admin=False),
|
||||
}
|
||||
write_json(OUT_DIR / "baseline_drawer_visibility.json", visibility)
|
||||
|
||||
route_contracts = {
|
||||
"source": "frontend route parsing and current parity matrix",
|
||||
"routes": ROUTE_QUERY_CONTRACTS,
|
||||
}
|
||||
write_json(OUT_DIR / "baseline_route_query_contracts.json", route_contracts)
|
||||
|
||||
payload_contracts = {
|
||||
"source": "current frontend API consumption contracts",
|
||||
"apis": CRITICAL_API_PAYLOAD_CONTRACTS,
|
||||
}
|
||||
write_json(OUT_DIR / "baseline_api_payload_contracts.json", payload_contracts)
|
||||
|
||||
validation = {
|
||||
"source": str(PAGE_STATUS_FILE.relative_to(ROOT)),
|
||||
"errors": validate_drawer_page_contract(raw),
|
||||
}
|
||||
write_json(OUT_DIR / "baseline_drawer_contract_validation.json", validation)
|
||||
|
||||
print("Generated baseline snapshots under", OUT_DIR)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user