feat: finalize portal no-iframe migration baseline and archive change

This commit is contained in:
egg
2026-02-11 13:25:03 +08:00
parent cd54d7cdcb
commit ccab10bee8
117 changed files with 6673 additions and 1098 deletions

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

View File

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

View File

@@ -0,0 +1,4 @@
{
"source": "data/page_status.json",
"errors": []
}

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

View File

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

View File

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

View File

@@ -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.

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

View File

@@ -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.

View File

@@ -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 檢查一致。

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

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

View File

@@ -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.

View File

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

View File

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

View File

@@ -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:

View File

@@ -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.

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

View File

@@ -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.

View File

@@ -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.

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

View 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 / Dont
- Do: prefer composable utility classes and shared Vue components.
- Do: keep style changes scoped to one route family per batch.
- Dont: introduce new long inline `<style>` blocks in templates.
- Dont: mix unrelated refactors with migration styling tasks.

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

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

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

View File

@@ -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.

File diff suppressed because it is too large Load Diff

View File

@@ -5,16 +5,20 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "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" "test": "node --test tests/*.test.js"
}, },
"devDependencies": { "devDependencies": {
"autoprefixer": "^10.4.24",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"vite": "^6.3.0" "vite": "^6.3.0"
}, },
"dependencies": { "dependencies": {
"@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue": "^6.0.4",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"vue": "^3.5.27", "vue": "^3.5.27",
"vue-echarts": "^8.0.1" "vue-echarts": "^8.0.1",
"vue-router": "^4.6.4"
} }
} }

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -3,7 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue';
import { apiGet } from '../core/api.js'; import { apiGet } from '../core/api.js';
import { NON_QUALITY_HOLD_REASON_SET } from '../wip-shared/constants.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 AgeDistribution from './components/AgeDistribution.vue';
import DistributionTable from './components/DistributionTable.vue'; import DistributionTable from './components/DistributionTable.vue';

View File

@@ -1,7 +1,7 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import Pagination from '../../wip-shared/components/Pagination.vue'; import Pagination from '../../shared-ui/components/PaginationControl.vue';
const props = defineProps({ const props = defineProps({
lots: { lots: {

View File

@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, reactive } from 'vue'; import { computed, reactive } from 'vue';
import Pagination from '../../shared-ui/components/PaginationControl.vue';
const props = defineProps({ const props = defineProps({
items: { items: {
@@ -22,9 +23,6 @@ const props = defineProps({
const emit = defineEmits(['prev-page', 'next-page']); 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 pageSummary = computed(() => {
const page = Number(props.pagination?.page || 1); const page = Number(props.pagination?.page || 1);
const perPage = Number(props.pagination?.perPage || 50); const perPage = Number(props.pagination?.perPage || 50);
@@ -39,6 +37,12 @@ const pageSummary = computed(() => {
return `顯示 ${start} - ${end} / ${total.toLocaleString('zh-TW')}`; 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) { function formatNumber(value) {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
return '-'; return '-';
@@ -131,11 +135,14 @@ function hideTip() {
</table> </table>
</div> </div>
<div class="pagination"> <Pagination
<button type="button" :disabled="!canPrev" @click="emit('prev-page')">Prev</button> :visible="Number(pagination.totalPages || 1) > 1"
<span class="page-info">Page {{ pagination.page || 1 }} / {{ pagination.totalPages || 1 }}</span> :page="Number(pagination.page || 1)"
<button type="button" :disabled="!canNext" @click="emit('next-page')">Next</button> :total-pages="Number(pagination.totalPages || 1)"
</div> :info-text="pageInfo"
@prev="emit('prev-page')"
@next="emit('next-page')"
/>
</section> </section>
<Teleport to="body"> <Teleport to="body">

View File

@@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { apiGet } from '../core/api.js'; 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 SummaryCards from '../hold-detail/components/SummaryCards.vue';
import FilterBar from './components/FilterBar.vue'; import FilterBar from './components/FilterBar.vue';

View File

@@ -1,7 +1,7 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import Pagination from '../../wip-shared/components/Pagination.vue'; import Pagination from '../../shared-ui/components/PaginationControl.vue';
const props = defineProps({ const props = defineProps({
lots: { lots: {

View File

@@ -2,7 +2,7 @@
import { reactive, ref } from 'vue'; import { reactive, ref } from 'vue';
import { apiGet, ensureMesApiAvailable } from '../core/api.js'; 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 FilterBar from './components/FilterBar.vue';
import KpiCards from './components/KpiCards.vue'; import KpiCards from './components/KpiCards.vue';

View File

@@ -1,7 +1,7 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import Pagination from '../../wip-shared/components/Pagination.vue'; import Pagination from '../../shared-ui/components/PaginationControl.vue';
const props = defineProps({ const props = defineProps({
data: { data: {

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

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

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

View 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');

View 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: '/' };
});

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

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

View File

@@ -0,0 +1,6 @@
<template>
<div class="panel">
<h2>Portal Shell Ready</h2>
<p>請由左側抽屜選擇頁面此殼層提供 router 導覽抽屜可見性與健康狀態治理</p>
</div>
</template>

View File

@@ -1,8 +1,8 @@
import './portal.css'; import './portal.css';
(function initPortal() { (function initPortal() {
const sidebarItems = document.querySelectorAll('.sidebar-item[data-target]'); const sidebarItems = document.querySelectorAll('.sidebar-item[data-route]');
const frames = document.querySelectorAll('iframe'); const routeStatus = document.getElementById('routeStatus');
const healthDot = document.getElementById('healthDot'); const healthDot = document.getElementById('healthDot');
const healthLabel = document.getElementById('healthLabel'); const healthLabel = document.getElementById('healthLabel');
const healthPopup = document.getElementById('healthPopup'); const healthPopup = document.getElementById('healthPopup');
@@ -18,49 +18,6 @@ import './portal.css';
const routeCacheHitRate = document.getElementById('routeCacheHitRate'); const routeCacheHitRate = document.getElementById('routeCacheHitRate');
const routeCacheDegraded = document.getElementById('routeCacheDegraded'); 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() { function toggleHealthPopup() {
if (!healthPopup) return; if (!healthPopup) return;
healthPopup.classList.toggle('show'); 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() { async function checkHealth() {
if (!healthDot || !healthLabel) return; if (!healthDot || !healthLabel) return;
try { try {
@@ -143,9 +112,7 @@ import './portal.css';
} }
const routeCache = data.route_cache || {}; const routeCache = data.route_cache || {};
if (routeCacheMode) { if (routeCacheMode) routeCacheMode.textContent = routeCache.mode || '--';
routeCacheMode.textContent = routeCache.mode || '--';
}
if (routeCacheHitRate) { if (routeCacheHitRate) {
const l1 = routeCache.l1_hit_rate ?? '--'; const l1 = routeCache.l1_hit_rate ?? '--';
const l2 = routeCache.l2_hit_rate ?? '--'; const l2 = routeCache.l2_hit_rate ?? '--';
@@ -173,15 +140,15 @@ import './portal.css';
sidebarItems.forEach((item) => { sidebarItems.forEach((item) => {
item.addEventListener('click', () => { 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) { markActiveSidebar();
activateTab(sidebarItems[0].dataset.target, sidebarItems[0].dataset.toolSrc || null);
}
window.toggleHealthPopup = toggleHealthPopup; window.toggleHealthPopup = toggleHealthPopup;
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!e.target.closest('#healthStatus') && !e.target.closest('#healthPopup') && healthPopup) { if (!e.target.closest('#healthStatus') && !e.target.closest('#healthPopup') && healthPopup) {
healthPopup.classList.remove('show'); healthPopup.classList.remove('show');
@@ -190,6 +157,4 @@ import './portal.css';
checkHealth(); checkHealth();
setInterval(checkHealth, 30000); setInterval(checkHealth, 30000);
window.addEventListener('resize', setFrameHeight);
setFrameHeight();
})(); })();

View File

@@ -30,8 +30,6 @@
.sidebar-item { .sidebar-item {
display: block; display: block;
width: 100%; width: 100%;
border: none;
background: none;
text-align: left; text-align: left;
padding: 8px 14px; padding: 8px 14px;
font-size: 13px; font-size: 13px;
@@ -53,3 +51,19 @@
font-weight: 600; font-weight: 600;
border-right: 3px solid #667eea; 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;
}

View File

@@ -128,7 +128,7 @@ function handleManualRefresh() {
<div v-if="errorMessage" class="error-banner">{{ errorMessage }}</div> <div v-if="errorMessage" class="error-banner">{{ errorMessage }}</div>
<main class="qc-gate-content"> <main class="qc-gate-content">
<section class="panel chart-panel"> <section class="panel chart-panel" :class="{ 'is-refreshing': refreshing }">
<div class="panel-header"> <div class="panel-header">
<h2>站點等待時間分布</h2> <h2>站點等待時間分布</h2>
<span class="panel-hint">點擊圖表區段可篩選下方 LOT 清單</span> <span class="panel-hint">點擊圖表區段可篩選下方 LOT 清單</span>
@@ -146,7 +146,7 @@ function handleManualRefresh() {
</template> </template>
</section> </section>
<section class="panel table-panel"> <section class="panel table-panel" :class="{ 'is-refreshing': refreshing }">
<div class="panel-header"> <div class="panel-header">
<h2>LOT 明細</h2> <h2>LOT 明細</h2>
<button <button

View File

@@ -124,6 +124,12 @@ body {
border-radius: 12px; border-radius: 12px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
overflow: hidden; 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 { .panel-header {
@@ -160,6 +166,22 @@ body {
padding-bottom: 8px; 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 { .qc-gate-chart {
min-height: 320px; min-height: 320px;
padding: 10px 14px 2px; padding: 10px 14px 2px;
@@ -259,6 +281,16 @@ body {
background: #f8fafc; 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 { .cell-number {
text-align: right; text-align: right;
} }

View File

@@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { apiGet, ensureMesApiAvailable } from '../core/api.js'; 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 { MATRIX_STATUS_COLUMNS, STATUS_DISPLAY_MAP, normalizeStatus } from '../resource-shared/constants.js';
import EquipmentGrid from './components/EquipmentGrid.vue'; import EquipmentGrid from './components/EquipmentGrid.vue';

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

View File

@@ -0,0 +1,5 @@
import { useAutoRefresh as useAutoRefreshBase } from '../wip-shared/composables/useAutoRefresh.js';
export function useAutoRefresh(options = {}) {
return useAutoRefreshBase(options);
}

View File

@@ -0,0 +1,5 @@
import { useAutocomplete as useAutocompleteBase } from '../wip-shared/composables/useAutocomplete.js';
export function useAutocomplete(options = {}) {
return useAutocompleteBase(options);
}

View 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,
};
}

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

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

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

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

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

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

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

View 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 exemplarVue 元件化 + 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>

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

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

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

View 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,
};
}

View File

@@ -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() { createApp(App).mount('#app');
// ============================================================
// 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());
});
})();

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

View File

@@ -3,7 +3,7 @@ import { computed, reactive, ref } from 'vue';
import { apiGet } from '../core/api.js'; import { apiGet } from '../core/api.js';
import { buildWipDetailQueryParams } from '../core/wip-derive.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 FilterPanel from './components/FilterPanel.vue';
import LotDetailPanel from './components/LotDetailPanel.vue'; import LotDetailPanel from './components/LotDetailPanel.vue';

View File

@@ -2,7 +2,7 @@
import { reactive, watch } from 'vue'; import { reactive, watch } from 'vue';
import { apiGet } from '../../core/api.js'; import { apiGet } from '../../core/api.js';
import { useAutocomplete } from '../../wip-shared/composables/useAutocomplete.js'; import { useAutocomplete } from '../../shared-composables/useAutocomplete.js';
const props = defineProps({ const props = defineProps({
filters: { filters: {

View File

@@ -1,7 +1,7 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import Pagination from '../../wip-shared/components/Pagination.vue'; import Pagination from '../../shared-ui/components/PaginationControl.vue';
const props = defineProps({ const props = defineProps({
data: { data: {

View File

@@ -6,7 +6,7 @@ import {
buildWipOverviewQueryParams, buildWipOverviewQueryParams,
splitHoldByType, splitHoldByType,
} from '../core/wip-derive.js'; } 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 FilterPanel from './components/FilterPanel.vue';
import MatrixTable from './components/MatrixTable.vue'; import MatrixTable from './components/MatrixTable.vue';

View File

@@ -2,7 +2,7 @@
import { reactive, watch } from 'vue'; import { reactive, watch } from 'vue';
import { apiGet } from '../../core/api.js'; import { apiGet } from '../../core/api.js';
import { useAutocomplete } from '../../wip-shared/composables/useAutocomplete.js'; import { useAutocomplete } from '../../shared-composables/useAutocomplete.js';
const props = defineProps({ const props = defineProps({
filters: { filters: {
@@ -111,23 +111,23 @@ function onSelect(field, value) {
<button type="button" class="btn-primary" @click="applyFilters">套用篩選</button> <button type="button" class="btn-primary" @click="applyFilters">套用篩選</button>
<button type="button" class="btn-secondary" @click="clearFilters">清除篩選</button> <button type="button" class="btn-secondary" @click="clearFilters">清除篩選</button>
<div class="active-filters"> <TransitionGroup name="filter-chip" tag="div" class="active-filters">
<span v-if="filters.workorder" class="filter-tag"> <span v-if="filters.workorder" key="workorder" class="filter-tag">
WO: {{ filters.workorder }} WO: {{ filters.workorder }}
<span class="remove" @click="removeFilter('workorder')">×</span> <span class="remove" @click="removeFilter('workorder')">×</span>
</span> </span>
<span v-if="filters.lotid" class="filter-tag"> <span v-if="filters.lotid" key="lotid" class="filter-tag">
Lot: {{ filters.lotid }} Lot: {{ filters.lotid }}
<span class="remove" @click="removeFilter('lotid')">×</span> <span class="remove" @click="removeFilter('lotid')">×</span>
</span> </span>
<span v-if="filters.package" class="filter-tag"> <span v-if="filters.package" key="package" class="filter-tag">
Pkg: {{ filters.package }} Pkg: {{ filters.package }}
<span class="remove" @click="removeFilter('package')">×</span> <span class="remove" @click="removeFilter('package')">×</span>
</span> </span>
<span v-if="filters.type" class="filter-tag"> <span v-if="filters.type" key="type" class="filter-tag">
Type: {{ filters.type }} Type: {{ filters.type }}
<span class="remove" @click="removeFilter('type')">×</span> <span class="remove" @click="removeFilter('type')">×</span>
</span> </span>
</div> </TransitionGroup>
</section> </section>
</template> </template>

View File

@@ -125,6 +125,24 @@
font-weight: 700; 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 { .overview-summary-row {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }

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

View File

@@ -13,6 +13,7 @@ export default defineConfig(({ mode }) => ({
rollupOptions: { rollupOptions: {
input: { input: {
portal: resolve(__dirname, 'src/portal/main.js'), 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-overview': resolve(__dirname, 'src/wip-overview/index.html'),
'wip-detail': resolve(__dirname, 'src/wip-detail/index.html'), 'wip-detail': resolve(__dirname, 'src/wip-detail/index.html'),
'hold-detail': resolve(__dirname, 'src/hold-detail/index.html'), 'hold-detail': resolve(__dirname, 'src/hold-detail/index.html'),

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-11

View File

@@ -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=1admin_only=false
- `drawer-2`歷史報表order=2admin_only=false
- `drawer`查詢工具order=3admin_only=false
- `dev-tools`開發工具order=4admin_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 snapshotadmin / non-admin
- route response smoke snapshotHTTP status + critical payload keys
- critical page JSON schema snapshotssummary/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 routes100% 回應 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 errorcritical 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 延後到二階段?

View File

@@ -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 baselineadmin / non-admin
- route + query contract baselineP0/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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

@@ -2,15 +2,15 @@
Define stable requirements for full-vite-page-modularization. Define stable requirements for full-vite-page-modularization.
## Requirements ## Requirements
### Requirement: Major Pages SHALL be Managed by Vite Modules ### 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 #### Scenario: Portal shell module loading
- **WHEN** the portal page is rendered - **WHEN** the portal experience is rendered
- **THEN** it MUST load its behavior from a Vite-built module asset when available - **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 - **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 ### 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. 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 - **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 ### 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 #### Scenario: User follows existing drill-down path
- **WHEN** the user navigates from summary page to detail views - **WHEN** the user navigates from summary page to detail views
- **THEN** the resulting flow and parameter semantics MUST match the established baseline behavior - **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 ### 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. Vite module structure MUST keep compute logic decoupled from DOM wiring so additional backend-to-frontend computation shifts can be added safely.

View 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

View File

@@ -2,19 +2,27 @@
Define stable requirements for migration-gates-and-rollout. Define stable requirements for migration-gates-and-rollout.
## Requirements ## Requirements
### Requirement: Migration Gates SHALL Define Cutover Readiness ### 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 #### Scenario: Gate evaluation before cutover
- **WHEN** release is prepared for final cutover - **WHEN** release is prepared for final cutover
- **THEN** all required migration gates MUST pass or cutover SHALL be blocked - **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 ### 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 #### Scenario: Rollback execution
- **WHEN** post-cutover validation fails critical checks - **WHEN** post-cutover validation fails critical checks
- **THEN** operators MUST be able to execute documented rollback steps to restore previous stable behavior - **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 ### 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. Cutover readiness gates MUST include resilience checks for pool exhaustion handling, circuit-breaker fail-fast behavior, and recovery flow.

View File

@@ -3,9 +3,8 @@ Define stable requirements for portal-drawer-navigation.
## Requirements ## Requirements
### Requirement: Portal Navigation SHALL Group Entries by Functional Drawers ### 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 #### Scenario: Drawer grouping visibility
- **WHEN** users open the portal - **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 - **THEN** the drawer and all its pages SHALL NOT be rendered in the sidebar
#### Scenario: Empty drawer visibility #### 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 - **THEN** the drawer group title SHALL NOT be rendered
### Requirement: Existing Page Behavior SHALL Remain Compatible ### 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 #### 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 - **THEN** the corresponding original route SHALL be loaded without changing page business logic behavior
#### Scenario: Iframe lazy-load continuity #### Scenario: Direct navigation without iframe
- **WHEN** a sidebar item is clicked for the first time - **WHEN** a sidebar item is clicked
- **THEN** the iframe SHALL lazy-load its content from the page's route, consistent with current behavior - **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 ### Requirement: Drawer Configuration and Visibility SHALL Remain Deterministic During Migration
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. 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 #### Scenario: Non-admin visible drawer pages remain stable
- **WHEN** the application starts and `page_status.json` has no `drawers` field - **WHEN** a non-admin user opens the portal after migration
- **THEN** the system SHALL create three default drawers (報表類, 查詢類, 開發工具) - **THEN** only pages with released visibility in non-admin drawers SHALL be visible
- **THEN** the system SHALL assign each existing page to its historically correct drawer - **THEN** admin-only drawers SHALL remain hidden
- **THEN** the system SHALL persist the updated configuration immediately
#### Scenario: Subsequent startup #### Scenario: Admin visible drawer pages remain stable
- **WHEN** the application starts and `page_status.json` already contains a `drawers` field - **WHEN** an admin user opens the portal after migration
- **THEN** the system SHALL NOT modify the existing drawer configuration - **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

View 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

View 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

View File

@@ -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** 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 - **THEN** the HTML SHALL NOT pass through Jinja2 template rendering
#### Scenario: Page works in portal iframe #### Scenario: Page works as top-level navigation target
- **WHEN** the pure Vite page is loaded inside the portal iframe - **WHEN** a pure Vite page is opened from portal direct navigation
- **THEN** the page SHALL render correctly within the iframe context - **THEN** the page SHALL render correctly as a top-level route without iframe embedding dependency
- **THEN** CSP `frame-ancestors 'self'` SHALL allow the embedding - **THEN** page functionality SHALL NOT rely on portal-managed frame lifecycle
### Requirement: Vite config SHALL support Vue SFC and HTML entry points ### 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. The Vite build configuration SHALL support Vue Single File Components alongside existing vanilla JS entries.

View 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