From dcbf6dcf1fbfe55b613d23c6f3d9b723051ff2cb Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 9 Feb 2026 14:52:14 +0800 Subject: [PATCH] feat(tables): migrate /tables page from Jinja2 to Vue 3 + Vite Rewrite 237-line vanilla JS + Jinja2 template into Vue 3 SFC components (App.vue, TableCatalog.vue, DataViewer.vue, useTableData composable). Establishes apiPost POST request pattern for pure Vite pages. Removes templates/index.html, updates Vite entry to HTML, and Flask route to send_from_directory. Includes sql_fragments WHERE_CLAUSE escaping fix, updated integration tests, and OpenSpec artifact archive. Co-Authored-By: Claude Opus 4.6 --- README.md | 31 +- frontend/package.json | 2 +- frontend/src/tables/App.vue | 123 ++++ frontend/src/tables/components/DataViewer.vue | 181 ++++++ .../src/tables/components/TableCatalog.vue | 73 +++ .../src/tables/composables/useTableData.js | 200 ++++++ frontend/src/tables/index.html | 12 + frontend/src/tables/main.js | 238 +------ frontend/src/tables/style.css | 457 ++++++++++++++ frontend/vite.config.js | 2 +- .../.openspec.yaml | 2 + .../2026-02-09-migrate-tables-vue/design.md | 63 ++ .../2026-02-09-migrate-tables-vue/proposal.md | 30 + .../specs/tables-query-page/spec.md | 84 +++ .../specs/vue-vite-page-architecture/spec.md | 41 ++ .../2026-02-09-migrate-tables-vue/tasks.md | 26 + openspec/specs/tables-query-page/spec.md | 88 +++ .../specs/vue-vite-page-architecture/spec.md | 19 + src/mes_dashboard/app.py | 25 +- src/mes_dashboard/services/sql_fragments.py | 9 +- src/mes_dashboard/templates/index.html | 589 ------------------ tests/test_resource_cache.py | 18 + tests/test_template_integration.py | 8 +- 23 files changed, 1483 insertions(+), 838 deletions(-) create mode 100644 frontend/src/tables/App.vue create mode 100644 frontend/src/tables/components/DataViewer.vue create mode 100644 frontend/src/tables/components/TableCatalog.vue create mode 100644 frontend/src/tables/composables/useTableData.js create mode 100644 frontend/src/tables/index.html create mode 100644 frontend/src/tables/style.css create mode 100644 openspec/changes/archive/2026-02-09-migrate-tables-vue/.openspec.yaml create mode 100644 openspec/changes/archive/2026-02-09-migrate-tables-vue/design.md create mode 100644 openspec/changes/archive/2026-02-09-migrate-tables-vue/proposal.md create mode 100644 openspec/changes/archive/2026-02-09-migrate-tables-vue/specs/tables-query-page/spec.md create mode 100644 openspec/changes/archive/2026-02-09-migrate-tables-vue/specs/vue-vite-page-architecture/spec.md create mode 100644 openspec/changes/archive/2026-02-09-migrate-tables-vue/tasks.md create mode 100644 openspec/specs/tables-query-page/spec.md delete mode 100644 src/mes_dashboard/templates/index.html diff --git a/README.md b/README.md index c3a9fe4..71f96ab 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,15 @@ | 部署自動化 | ✅ 已完成 | | Portal 動態抽屜導覽管理 | ✅ 已完成 | | QC-GATE 即時狀態報表(Vue 3 + Vite) | ✅ 已完成 | +| 數據表查詢頁面 Vue 3 遷移 | ✅ 已完成 | +| 設備快取 DataFrame TTL 一致性修復 | ✅ 已完成 | --- ## 開發歷史(Vite 重構後) +- 2026-02-09:完成數據表查詢頁面(`/tables`)Vue 3 遷移 — 第二個純 Vite 頁面,建立 `apiPost` POST 請求模式,237 行 vanilla JS 重寫為 Vue 3 SFC 元件。 +- 2026-02-09:修復設備快取 DataFrame TTL 一致性問題 — process-level DataFrame(30s TTL)過期後 derived index 仍為 ready,導致 `/api/resource/status` 回傳空資料。新增 Redis fallback reload。 - 2026-02-09:新增 QC-GATE 即時狀態報表 — 第一個純 Vue 3 + Vite 頁面(脫離 Jinja2),建立後續前端遷移架構模式。 - 2026-02-09:完成 Portal 動態抽屜導覽管理,sidebar drawer/page 配置改為 admin 可管理。 - 2026-02-07:完成 Flask + Vite 單一 port 架構切換,舊版 `DashBoard/` 停用。 @@ -450,9 +454,9 @@ A: 請確認瀏覽器允許下載檔案,並檢查查詢結果是否有資料 ### Portal 入口頁面 透過側邊欄抽屜分組導覽切換各功能模組: -- **報表類**:WIP 即時概況、QC-GATE 即時狀態 -- **查詢類**:WIP 明細查詢、Hold 狀態分析、設備狀態監控、設備歷史查詢、數據表查詢工具 -- **開發工具**(admin only):Excel 批次查詢、TMTT 不良分析等 +- **報表類**:WIP 即時概況、設備即時概況、設備歷史績效、QC-GATE 即時狀態 +- **查詢類**:設備維修查詢、批次追蹤工具、TMTT 不良分析 +- **開發工具**(admin only):數據表查詢、Excel 批次查詢、頁面管理、效能監控 - 抽屜/頁面配置可由管理員動態管理(新增、重排、刪除) ### WIP 即時概況 @@ -502,6 +506,15 @@ A: 請確認瀏覽器允許下載檔案,並檢查查詢結果是否有資料 - 站點排序依 DW_MES_SPEC_WORKCENTER_V 製程順序 - **技術架構**:第一個純 Vue 3 + Vite 頁面,完全脫離 Jinja2 +### 數據表查詢工具 + +- 顯示所有 DWH 表格的分類卡片目錄(即時數據表/現況快照表/歷史累積表/輔助表) +- 大表標記:超過 1,000 萬筆資料的表格顯示 badge 提示 +- 選擇表格後自動載入欄位資訊,每欄提供篩選輸入 +- 支援 Enter 鍵或「查詢」按鈕觸發查詢(預設回傳最近 1000 筆) +- 使用中的篩選條件顯示為可移除的 tag,支援一鍵清除全部 +- **技術架構**:第二個純 Vue 3 + Vite 頁面,使用 `apiPost` 建立 POST 請求模式 + ### 管理員功能 - LDAP 認證登入(支援本地測試模式) @@ -705,6 +718,16 @@ conda run -n mes-dashboard python scripts/run_cache_benchmarks.py --enforce ### 2026-02-09 +- 完成數據表查詢頁面(`/tables`)Vue 3 遷移: + - 237 行 vanilla JS + Jinja2 模板重寫為 Vue 3 SFC 元件(App.vue、TableCatalog.vue、DataViewer.vue) + - Vite entry 從 `main.js` 改為 `index.html`,Flask route 改為 `send_from_directory` + - 使用 `apiPost` 建立純 Vite 頁面 POST 請求模式 + - 移除 Jinja2 模板 `templates/index.html`,完全脫離 `window.MesApi` 依賴 +- 修復設備即時概況(`/resource`)資料為空問題: + - 根因:process-level DataFrame cache(30s TTL)過期後,derived index 仍標記 `ready: true` + - `_records_from_index()` 取得 `df=None` 時直接回傳空 list + - 修復:新增 `_get_cached_data()` fallback,從 Redis 重新載入 DataFrame +- 修正 `page_status.json` 設備頁面名稱:「機台狀態」→「設備即時概況」 - 新增 QC-GATE 即時狀態報表頁面(`/qc-gate`): - 第一個純 Vue 3 + Vite 頁面,完全脫離 Jinja2 模板 - ECharts 堆疊條圖顯示各 QC-GATE 站點 LOT 分佈(按 6hr 時間分級) @@ -816,5 +839,5 @@ conda run -n mes-dashboard python scripts/run_cache_benchmarks.py --enforce --- -**文檔版本**: 5.0 +**文檔版本**: 5.1 **最後更新**: 2026-02-09 diff --git a/frontend/package.json b/frontend/package.json index 3ccf2fa..bdef38a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite --host", - "build": "vite build && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html", + "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", "test": "node --test tests/*.test.js" }, "devDependencies": { diff --git a/frontend/src/tables/App.vue b/frontend/src/tables/App.vue new file mode 100644 index 0000000..276a8fe --- /dev/null +++ b/frontend/src/tables/App.vue @@ -0,0 +1,123 @@ + + + diff --git a/frontend/src/tables/components/DataViewer.vue b/frontend/src/tables/components/DataViewer.vue new file mode 100644 index 0000000..16928d8 --- /dev/null +++ b/frontend/src/tables/components/DataViewer.vue @@ -0,0 +1,181 @@ + + + diff --git a/frontend/src/tables/components/TableCatalog.vue b/frontend/src/tables/components/TableCatalog.vue new file mode 100644 index 0000000..d1400a4 --- /dev/null +++ b/frontend/src/tables/components/TableCatalog.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/tables/composables/useTableData.js b/frontend/src/tables/composables/useTableData.js new file mode 100644 index 0000000..1251762 --- /dev/null +++ b/frontend/src/tables/composables/useTableData.js @@ -0,0 +1,200 @@ +import { computed, reactive, ref } from 'vue'; + +import { apiGet, apiPost } from '../../core/api.js'; + +const QUERY_LIMIT = 1000; + +function normalizeTableConfig(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return {}; + } + return payload; +} + +function toDisplayError(error, fallback) { + return error?.message || fallback; +} + +function toTableModel(table) { + return { + name: String(table?.name || ''), + display_name: String(table?.display_name || table?.name || ''), + time_field: table?.time_field || null, + row_count: Number(table?.row_count || 0), + description: String(table?.description || ''), + }; +} + +export function useTableData() { + const tableConfig = ref({}); + const selectedTable = ref(null); + const columns = ref([]); + const rows = ref([]); + const rowCount = ref(0); + const hasQueried = ref(false); + const filters = reactive({}); + + const loadingConfig = ref(false); + const loadingColumns = ref(false); + const loadingQuery = ref(false); + + const pageError = ref(''); + const viewerError = ref(''); + + const activeFilterCount = computed(() => { + return Object.values(filters).filter((value) => String(value ?? '').trim().length > 0).length; + }); + + async function loadTableConfig() { + loadingConfig.value = true; + pageError.value = ''; + + try { + const response = await apiGet('/api/get_table_info'); + const payload = response?.success ? response.data : response; + tableConfig.value = normalizeTableConfig(payload); + } catch (error) { + pageError.value = toDisplayError(error, '載入表格設定失敗'); + } finally { + loadingConfig.value = false; + } + } + + function clearFilters() { + for (const key of Object.keys(filters)) { + delete filters[key]; + } + } + + function setFilter(column, value) { + const trimmed = String(value ?? '').trim(); + if (!trimmed) { + delete filters[column]; + return; + } + filters[column] = trimmed; + } + + function removeFilter(column) { + delete filters[column]; + } + + function resetViewerState() { + columns.value = []; + rows.value = []; + rowCount.value = 0; + hasQueried.value = false; + viewerError.value = ''; + clearFilters(); + } + + async function loadColumns() { + if (!selectedTable.value?.name) { + return; + } + + loadingColumns.value = true; + viewerError.value = ''; + + try { + const response = await apiPost('/api/get_table_columns', { + table_name: selectedTable.value.name, + }); + + if (response?.error) { + throw new Error(String(response.error)); + } + + columns.value = Array.isArray(response?.columns) ? response.columns : []; + } catch (error) { + columns.value = []; + viewerError.value = toDisplayError(error, '載入欄位資訊失敗'); + } finally { + loadingColumns.value = false; + } + } + + async function selectTable(table) { + if (!table?.name) { + return; + } + + selectedTable.value = toTableModel(table); + resetViewerState(); + await loadColumns(); + } + + function buildFilterPayload() { + const payload = {}; + for (const column of columns.value) { + const value = String(filters[column] ?? '').trim(); + if (value) { + payload[column] = value; + } + } + return payload; + } + + async function queryTable() { + if (!selectedTable.value?.name) { + return; + } + + hasQueried.value = true; + loadingQuery.value = true; + viewerError.value = ''; + + try { + const queryFilters = buildFilterPayload(); + const response = await apiPost('/api/query_table', { + table_name: selectedTable.value.name, + limit: QUERY_LIMIT, + time_field: selectedTable.value.time_field, + filters: Object.keys(queryFilters).length > 0 ? queryFilters : null, + }); + + if (response?.error) { + throw new Error(String(response.error)); + } + + rows.value = Array.isArray(response?.data) ? response.data : []; + rowCount.value = Number.isFinite(Number(response?.row_count)) + ? Number(response.row_count) + : rows.value.length; + } catch (error) { + rows.value = []; + rowCount.value = 0; + viewerError.value = toDisplayError(error, '查詢失敗'); + } finally { + loadingQuery.value = false; + } + } + + function closeViewer() { + selectedTable.value = null; + resetViewerState(); + } + + return { + tableConfig, + selectedTable, + columns, + filters, + rows, + rowCount, + hasQueried, + loadingConfig, + loadingColumns, + loadingQuery, + pageError, + viewerError, + activeFilterCount, + loadTableConfig, + selectTable, + setFilter, + removeFilter, + clearFilters, + queryTable, + closeViewer, + }; +} diff --git a/frontend/src/tables/index.html b/frontend/src/tables/index.html new file mode 100644 index 0000000..19b6911 --- /dev/null +++ b/frontend/src/tables/index.html @@ -0,0 +1,12 @@ + + + + + + MES 數據表查詢工具 + + +
+ + + diff --git a/frontend/src/tables/main.js b/frontend/src/tables/main.js index 4ae7f98..3cb2340 100644 --- a/frontend/src/tables/main.js +++ b/frontend/src/tables/main.js @@ -1,236 +1,6 @@ -import { ensureMesApiAvailable } from '../core/api.js'; -import { getPageContract } from '../core/field-contracts.js'; -import { buildResourceKpiFromHours } from '../core/compute.js'; -import { groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText } from '../core/table-tree.js'; +import { createApp } from 'vue'; -ensureMesApiAvailable(); -window.__MES_FRONTEND_CORE__ = { buildResourceKpiFromHours, groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText }; -window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {}; -window.__FIELD_CONTRACTS__['tables:result_table'] = getPageContract('tables', 'result_table'); +import App from './App.vue'; +import './style.css'; - - let currentTable = null; - let currentDisplayName = null; - let currentTimeField = null; - let currentColumns = []; - let currentFilters = {}; - - function toFilterInputId(column) { - return `filter_${encodeURIComponent(safeText(column))}`; - } - - function toJsSingleQuoted(value) { - return safeText(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); - } - - async function loadTableData(tableName, displayName, timeField) { - // Mark current selected table - document.querySelectorAll('.table-card').forEach(card => { - card.classList.remove('active'); - }); - event.currentTarget.classList.add('active'); - - currentTable = tableName; - currentDisplayName = displayName; - currentTimeField = timeField || null; - currentFilters = {}; - - const viewer = document.getElementById('dataViewer'); - const title = document.getElementById('viewerTitle'); - const content = document.getElementById('tableContent'); - const statsContainer = document.getElementById('statsContainer'); - - viewer.classList.add('active'); - title.textContent = `正在載入: ${displayName}`; - content.innerHTML = '
正在載入欄位資訊...
'; - statsContainer.innerHTML = ''; - - viewer.scrollIntoView({ behavior: 'smooth', block: 'start' }); - - try { - const data = await MesApi.post('/api/get_table_columns', { table_name: tableName }); - - if (data.error) { - content.innerHTML = `
${escapeHtml(data.error)}
`; - return; - } - - currentColumns = data.columns; - title.textContent = `${displayName} (${currentColumns.length} 欄位)`; - - renderFilterControls(); - } catch (error) { - content.innerHTML = `
請求失敗: ${escapeHtml(error.message)}
`; - } - } - - function renderFilterControls() { - const statsContainer = document.getElementById('statsContainer'); - const content = document.getElementById('tableContent'); - - statsContainer.innerHTML = ` -
-
-
表名
-
${escapeHtml(currentTable)}
-
-
-
欄位數
-
${currentColumns.length}
-
- 在下方輸入框填入篩選條件 (模糊匹配) - - -
-
- `; - - let html = ''; - html += ''; - currentColumns.forEach(col => { - html += ``; - }); - html += ''; - - html += ''; - currentColumns.forEach(col => { - const filterId = toFilterInputId(col); - const jsCol = toJsSingleQuoted(col); - html += ``; - }); - html += ''; - - html += ''; - html += ''; - html += '
${escapeHtml(col)}
請輸入篩選條件後點擊「查詢」,或直接點擊「查詢」載入最後 1000 筆資料
'; - - content.innerHTML = html; - } - - function updateFilter(column, value) { - if (value && value.trim()) { - currentFilters[column] = value.trim(); - } else { - delete currentFilters[column]; - } - renderActiveFilters(); - } - - function renderActiveFilters() { - const container = document.getElementById('activeFilters'); - if (!container) return; - - const filterKeys = Object.keys(currentFilters); - if (filterKeys.length === 0) { - container.innerHTML = ''; - return; - } - - let html = ''; - filterKeys.forEach(col => { - html += `${escapeHtml(col)}: ${escapeHtml(currentFilters[col])} ×`; - }); - container.innerHTML = html; - } - - function removeFilter(column) { - delete currentFilters[column]; - const input = document.getElementById(toFilterInputId(column)); - if (input) input.value = ''; - renderActiveFilters(); - } - - function clearFilters() { - currentFilters = {}; - currentColumns.forEach(col => { - const input = document.getElementById(toFilterInputId(col)); - if (input) input.value = ''; - }); - renderActiveFilters(); - } - - function handleFilterKeypress(event) { - if (event.key === 'Enter') { - executeQuery(); - } - } - - async function executeQuery() { - const title = document.getElementById('viewerTitle'); - const tbody = document.getElementById('dataBody'); - - currentFilters = {}; - currentColumns.forEach(col => { - const input = document.getElementById(toFilterInputId(col)); - if (input && input.value.trim()) { - currentFilters[col] = input.value.trim(); - } - }); - renderActiveFilters(); - - title.textContent = `正在查詢: ${currentDisplayName}`; - tbody.innerHTML = `正在查詢資料...`; - - try { - const data = await MesApi.post('/api/query_table', { - table_name: currentTable, - limit: 1000, - time_field: currentTimeField, - filters: Object.keys(currentFilters).length > 0 ? currentFilters : null - }); - - if (data.error) { - tbody.innerHTML = `${escapeHtml(data.error)}`; - return; - } - - const filterCount = Object.keys(currentFilters).length; - const filterText = filterCount > 0 ? ` [${filterCount} 個篩選]` : ''; - title.textContent = `${currentDisplayName} (${data.row_count} 筆)${filterText}`; - - if (data.data.length === 0) { - tbody.innerHTML = `查無資料`; - return; - } - - let html = ''; - data.data.forEach(row => { - html += ''; - currentColumns.forEach(col => { - const value = row[col]; - if (value === null || value === undefined) { - html += 'NULL'; - } else { - html += `${escapeHtml(safeText(value))}`; - } - }); - html += ''; - }); - tbody.innerHTML = html; - } catch (error) { - tbody.innerHTML = `請求失敗: ${escapeHtml(error.message)}`; - } - } - - function closeViewer() { - document.getElementById('dataViewer').classList.remove('active'); - document.querySelectorAll('.table-card').forEach(card => { - card.classList.remove('active'); - }); - currentTable = null; - currentColumns = []; - currentFilters = {}; - } - - -Object.assign(window, { -loadTableData, -renderFilterControls, -updateFilter, -renderActiveFilters, -removeFilter, -clearFilters, -handleFilterKeypress, -executeQuery, -closeViewer, -}); +createApp(App).mount('#app'); diff --git a/frontend/src/tables/style.css b/frontend/src/tables/style.css new file mode 100644 index 0000000..9c5375c --- /dev/null +++ b/frontend/src/tables/style.css @@ -0,0 +1,457 @@ +:root { + --bg: #f5f7fb; + --surface: #ffffff; + --surface-muted: #f8f9fc; + --text: #1f2937; + --muted: #64748b; + --border: #dbe2ef; + --primary: #4f46e5; + --primary-strong: #4338ca; + --danger: #dc2626; + --warning: #b45309; + --shadow: 0 8px 24px rgba(15, 23, 42, 0.08); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + font-family: "Microsoft JhengHei", "Noto Sans TC", sans-serif; + color: var(--text); + background: var(--bg); + min-height: 100vh; +} + +#app { + min-height: 100vh; +} + +.tables-page { + padding: 20px; +} + +.container { + max-width: 1440px; + margin: 0 auto; + background: var(--surface); + border-radius: 12px; + box-shadow: var(--shadow); + overflow: hidden; +} + +.header { + background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); + color: #ffffff; + padding: 24px 28px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.header h1 { + margin: 0 0 8px; + font-size: 28px; +} + +.header p { + margin: 0; + opacity: 0.92; + font-size: 14px; +} + +.refresh-catalog-btn { + border: 1px solid rgba(255, 255, 255, 0.4); + background: rgba(255, 255, 255, 0.18); + color: #ffffff; + border-radius: 8px; + font-size: 13px; + font-weight: 700; + padding: 10px 14px; + cursor: pointer; + white-space: nowrap; +} + +.refresh-catalog-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.28); +} + +.refresh-catalog-btn:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.content { + padding: 26px; +} + +.error-banner { + margin-bottom: 16px; + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + border-radius: 8px; + padding: 12px 14px; + font-size: 14px; +} + +.loading-panel, +.empty-state { + background: var(--surface-muted); + border: 1px dashed var(--border); + border-radius: 8px; + padding: 28px; + text-align: center; + color: var(--muted); +} + +.table-category { + margin-bottom: 28px; +} + +.table-category:last-child { + margin-bottom: 0; +} + +.category-title { + margin: 0 0 14px; + padding-bottom: 10px; + font-size: 20px; + border-bottom: 2px solid rgba(79, 70, 229, 0.35); +} + +.table-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 14px; +} + +.table-card { + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px; + cursor: pointer; + transition: all 0.2s ease; +} + +.table-card:hover { + border-color: #8b83f8; + box-shadow: 0 6px 16px rgba(79, 70, 229, 0.15); + transform: translateY(-2px); +} + +.table-card.active { + border-color: var(--primary); + background: #eef2ff; +} + +.table-card.disabled { + opacity: 0.7; + cursor: wait; +} + +.table-name { + margin: 0 0 8px; + color: var(--primary); + font-size: 16px; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.table-info { + margin: 0 0 4px; + font-size: 13px; + color: #475569; +} + +.table-desc { + margin: 8px 0 0; + color: #64748b; + font-style: italic; + font-size: 12px; + line-height: 1.45; +} + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + padding: 2px 8px; + font-size: 11px; + color: #ffffff; + background: var(--primary); +} + +.badge.large { + background: var(--danger); +} + +.data-viewer { + margin-top: 24px; +} + +.viewer-header { + background: var(--primary); + color: #ffffff; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 14px 18px; + border-radius: 10px 10px 0 0; +} + +.viewer-header h3 { + margin: 0; + font-size: 18px; +} + +.close-btn { + border: 0; + background: rgba(255, 255, 255, 0.2); + color: #ffffff; + border-radius: 6px; + padding: 8px 14px; + cursor: pointer; + font-size: 13px; +} + +.close-btn:hover { + background: rgba(255, 255, 255, 0.3); +} + +.stats { + display: flex; + gap: 16px; + flex-wrap: wrap; + align-items: center; + border: 1px solid var(--border); + border-top: 0; + padding: 14px 16px; + background: var(--surface-muted); +} + +.stat-item { + display: flex; + flex-direction: column; + min-width: 120px; +} + +.stat-label { + font-size: 12px; + color: var(--muted); +} + +.stat-value { + margin-top: 3px; + font-size: 18px; + font-weight: 700; + color: var(--primary); +} + +.stat-value-table-name { + font-size: 14px; +} + +.filter-hint { + font-size: 12px; + color: var(--muted); +} + +.query-btn, +.clear-btn { + border: 0; + border-radius: 6px; + padding: 10px 16px; + color: #ffffff; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.query-btn { + margin-left: auto; + background: var(--primary); +} + +.query-btn:hover:not(:disabled) { + background: var(--primary-strong); +} + +.clear-btn { + background: #64748b; +} + +.clear-btn:hover:not(:disabled) { + background: #475569; +} + +.query-btn:disabled, +.clear-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.active-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + border-left: 1px solid var(--border); + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + padding: 10px 16px; + background: #fafbff; +} + +.filter-tag { + display: inline-flex; + align-items: center; + gap: 6px; + background: #e0e7ff; + color: var(--primary); + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; +} + +.filter-tag .remove { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; +} + +.filter-tag .remove:hover { + color: var(--danger); +} + +.table-container { + border: 1px solid var(--border); + border-top: 0; + border-radius: 0 0 10px 10px; + max-height: 620px; + overflow: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +thead { + position: sticky; + top: 0; + background: #f8fafc; + z-index: 1; +} + +th { + padding: 10px; + text-align: left; + border-bottom: 2px solid #dde5f2; + color: #334155; + white-space: nowrap; +} + +.filter-row th { + padding: 8px 10px; + border-bottom: 1px solid var(--border); + background: #eef2f7; +} + +.filter-row input { + width: 100%; + border: 1px solid #cbd5e1; + border-radius: 4px; + padding: 6px 8px; + font-size: 12px; +} + +.filter-row input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.18); +} + +td { + padding: 9px 10px; + border-bottom: 1px solid #edf2f7; + color: #1e293b; +} + +tbody tr:hover { + background: #f8fafc; +} + +.loading, +.error, +.empty-hint { + text-align: center; + padding: 28px; +} + +.loading { + color: var(--primary); +} + +.error { + color: #991b1b; + background: #fef2f2; +} + +.empty-hint { + color: var(--muted); +} + +.null-value { + color: #94a3b8; +} + +@media (max-width: 900px) { + .tables-page { + padding: 12px; + } + + .content { + padding: 16px; + } + + .header { + padding: 18px; + flex-direction: column; + } + + .header h1 { + font-size: 24px; + } + + .table-grid { + grid-template-columns: 1fr; + } + + .stats { + flex-direction: column; + align-items: stretch; + } + + .query-btn, + .clear-btn { + width: 100%; + margin-left: 0; + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index caceb33..cf0eed7 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -20,7 +20,7 @@ export default defineConfig(({ mode }) => ({ 'resource-history': resolve(__dirname, 'src/resource-history/main.js'), 'job-query': resolve(__dirname, 'src/job-query/main.js'), 'excel-query': resolve(__dirname, 'src/excel-query/main.js'), - tables: resolve(__dirname, 'src/tables/main.js'), + tables: resolve(__dirname, 'src/tables/index.html'), 'query-tool': resolve(__dirname, 'src/query-tool/main.js'), 'tmtt-defect': resolve(__dirname, 'src/tmtt-defect/main.js'), 'qc-gate': resolve(__dirname, 'src/qc-gate/index.html') diff --git a/openspec/changes/archive/2026-02-09-migrate-tables-vue/.openspec.yaml b/openspec/changes/archive/2026-02-09-migrate-tables-vue/.openspec.yaml new file mode 100644 index 0000000..9bc4ae2 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-tables-vue/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-09 diff --git a/openspec/changes/archive/2026-02-09-migrate-tables-vue/design.md b/openspec/changes/archive/2026-02-09-migrate-tables-vue/design.md new file mode 100644 index 0000000..cdd4cef --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-tables-vue/design.md @@ -0,0 +1,63 @@ +## Context + +Tables 頁面(`/tables`)是開發者工具頁面,允許瀏覽 19 張 DWH 表的欄位與內容。目前架構: +- Jinja2 模板 `index.html` extends `_base.html`,server-render `TABLES_CONFIG` 為表格卡片 +- vanilla JS (237 行) 用 DOM 操作管理狀態,透過 `window.MesApi.post()` 呼叫 API +- 兩個 POST API:`/api/get_table_columns`、`/api/query_table`;一個 GET API:`/api/get_table_info` + +QC-GATE 遷移已建立 Vue 3 + Vite 純前端架構模式(GET-only),本次需補齊 POST 請求模式。 + +## Goals / Non-Goals + +**Goals:** +- 將 Tables 頁面完整遷移為 Vue 3 SFC,複用 QC-GATE 架構模式 +- 建立 POST 請求在純 Vite 頁面中的標準做法(`apiPost` from `core/api.js`) +- 表格配置改由前端 `apiGet('/api/get_table_info')` 動態取得,脫離 Jinja2 context +- 遷移完成後移除 Jinja2 模板 `templates/index.html` + +**Non-Goals:** +- 不修改後端 API 邏輯或 SQL 查詢(保持現有 `/api/query_table`、`/api/get_table_columns` 不變) +- 不改變 CSRF 策略(現有 CSRF 僅 enforce `/admin/*` 路徑,Tables API 不受影響) +- 不增加新功能(如分頁、排序、匯出),僅 1:1 功能遷移 +- 不建立共用 Vue 元件庫(本次僅 Tables 頁面內部元件化) + +## Decisions + +### D1: CSRF token 不需額外處理 +**選擇**:Tables 的 POST API 不需 CSRF token +**理由**:`csrf.py` 的 `should_enforce_csrf()` 僅對 `/admin/*` 路徑啟用 CSRF。`/api/query_table` 和 `/api/get_table_columns` 不在 enforce 範圍內。`apiPost()` 已內建 CSRF header 邏輯(從 `` 讀取),即使沒有 meta tag 也只是發送空字串,不會失敗。 +**替代方案**:新增 CSRF token API endpoint — 不需要,因為 Tables API 本身就不 enforce。 + +### D2: 表格配置從 API 動態取得 +**選擇**:前端在 mount 時呼叫 `GET /api/get_table_info` 取得 `TABLES_CONFIG` +**理由**:該 endpoint 已存在(`app.py:453`),直接返回 `TABLES_CONFIG` dict。無需建立新 API。 +**替代方案**:將 config 打包成靜態 JSON — 不適合,config 含 row_count 等可能更新的資訊。 + +### D3: Vite entry 改為 HTML entry point +**選擇**:`vite.config.js` 中 tables entry 從 `src/tables/main.js` 改為 `src/tables/index.html` +**理由**:與 QC-GATE 模式一致,HTML entry 讓 Vite 處理完整的 HTML → JS → CSS pipeline。 +**影響**:`npm run build` 會輸出 `tables.html`、`tables.js`、`tables.css` 到 `static/dist/`。 + +### D4: 元件拆分策略 +**選擇**:3 個 Vue 元件 + 1 個 composable +- `App.vue` — 根佈局,管理 loading/error 狀態 +- `TableCatalog.vue` — 表格卡片目錄(分類顯示) +- `DataViewer.vue` — 資料檢視器(欄位篩選 + 查詢結果表格) +- `useTableData.js` — composable 封裝 API 呼叫和狀態管理 + +**理由**:對應原始 UI 的兩個主要區塊(表格選擇 / 資料檢視),職責清晰。 + +### D5: 現有 vanilla JS main.js 直接替換 +**選擇**:將現有 `frontend/src/tables/main.js` (237 行) 替換為 Vue 3 bootstrap 入口(~7 行),原始邏輯分散至 Vue 元件和 composable 中。 +**理由**:vanilla JS 全部是 DOM 操作,無法漸進式遷移,需整體重寫。 + +## Risks / Trade-offs + +- **[風險] 大表警示標記遺失**:Jinja2 模板中有 `{% if table.row_count > 10000000 %}` 顯示「大表」badge。 + → 遷移:在 `TableCatalog.vue` 中用 Vue 條件渲染實現相同邏輯。 + +- **[風險] Fallback inline script 移除**:`index.html` 含 ~200 行 fallback JS(Vite build 不存在時)。 + → 接受:Vite build 是 deployment 的標準流程,fallback 不再需要。 + +- **[風險] CSS 樣式差異**:原始 ~335 行 embedded CSS 需遷移至 `style.css`。 + → 遷移:提取核心樣式至獨立 CSS 檔案,與 QC-GATE 風格統一。 diff --git a/openspec/changes/archive/2026-02-09-migrate-tables-vue/proposal.md b/openspec/changes/archive/2026-02-09-migrate-tables-vue/proposal.md new file mode 100644 index 0000000..6191174 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-tables-vue/proposal.md @@ -0,0 +1,30 @@ +## Why + +Tables 頁面(`/tables`)是完全獨立的開發工具頁面,無跨頁面 drill-down 依賴,且行數最少(237 行 JS),是建立 POST/CSRF 請求模式的理想候選。QC-GATE 遷移已建立 GET-only 的 Vue 3 + Vite 架構模式,現在需要補齊 POST 請求模式,為後續更複雜頁面遷移鋪路。 + +## What Changes + +- 將 `/tables` 頁面從 Jinja2 模板 + vanilla JS 遷移為純 Vue 3 + Vite SFC 架構 +- Flask route 從 `render_template()` 改為 `send_from_directory()`,不再傳入 `TABLES_CONFIG` context +- 前端改用 `/api/get_table_info` (GET) 取得表格配置,取代 Jinja2 server-render +- API 呼叫從 `window.MesApi.post()` 改為 `apiPost()` from `core/api.js` +- 純 Vite 頁面發出 POST 請求時需自行攜帶 CSRF token(透過 `` tag 或從 API 取得) +- Vite config entry 從 JS-only (`tables/main.js`) 改為 HTML entry (`tables/index.html`) +- 保留現有 Jinja2 模板作為 fallback 直到驗證完成後移除 + +## Capabilities + +### New Capabilities +- `tables-query-page`: 數據表查詢頁面的功能需求(表格選擇、動態欄位篩選、查詢結果顯示) + +### Modified Capabilities +- `vue-vite-page-architecture`: 新增 POST 請求 + CSRF token 處理模式(現有 spec 僅涵蓋 GET) + +## Impact + +- **前端**:`frontend/src/tables/` 整個目錄重寫(main.js → Vue 3 SFC 結構) +- **後端**:`app.py` 中 `/tables` route 改為 `send_from_directory` +- **Vite config**:tables entry 改為 HTML entry point +- **CSRF**:純 Vite 頁面無 Jinja2 `{{ csrf_token() }}`,需建立替代方案(API endpoint 或 cookie-based) +- **模板**:`templates/index.html` 遷移完成後可移除 +- **API**:現有 `/api/get_table_info`、`/api/get_table_columns`、`/api/query_table` 不變 diff --git a/openspec/changes/archive/2026-02-09-migrate-tables-vue/specs/tables-query-page/spec.md b/openspec/changes/archive/2026-02-09-migrate-tables-vue/specs/tables-query-page/spec.md new file mode 100644 index 0000000..9754b32 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-tables-vue/specs/tables-query-page/spec.md @@ -0,0 +1,84 @@ +## ADDED Requirements + +### Requirement: Tables page SHALL display categorized table catalog +The page SHALL display all configured DWH tables as clickable cards, grouped by category. + +#### Scenario: Table catalog rendering +- **WHEN** the page loads +- **THEN** the page SHALL fetch table configuration from `GET /api/get_table_info` +- **THEN** tables SHALL be displayed as cards grouped by category (即時數據表, 現況快照表, 歷史累積表, 輔助表) +- **THEN** each card SHALL show the table display name and description + +#### Scenario: Large table badge +- **WHEN** a table has `row_count` exceeding 10,000,000 +- **THEN** the card SHALL display a visual indicator (badge) marking it as a large table + +### Requirement: Tables page SHALL load column metadata on table selection +The page SHALL load and display column information when a table is selected from the catalog. + +#### Scenario: Select table from catalog +- **WHEN** user clicks a table card +- **THEN** the page SHALL call `POST /api/get_table_columns` with the table name +- **THEN** the data viewer panel SHALL open showing the table name and column count +- **THEN** a filter input row SHALL appear with one input per column + +#### Scenario: Active table indication +- **WHEN** a table is selected +- **THEN** the selected card SHALL have a visual active state +- **THEN** previously active cards SHALL be deactivated + +### Requirement: Tables page SHALL support column-level filtering +The page SHALL allow users to enter filter values per column and query the table data. + +#### Scenario: Enter filter and query +- **WHEN** user enters filter values in column inputs and clicks "查詢" +- **THEN** the page SHALL call `POST /api/query_table` with the table name, filters, limit (1000), and time_field +- **THEN** the result table SHALL display returned rows with column headers +- **THEN** the title SHALL show the table name, row count, and active filter count + +#### Scenario: Enter key triggers query +- **WHEN** user presses Enter in any filter input +- **THEN** the query SHALL execute as if the "查詢" button was clicked + +#### Scenario: Active filter display +- **WHEN** filters are applied +- **THEN** active filters SHALL be displayed as removable tags above the result table +- **THEN** clicking a tag's remove button SHALL clear that filter + +#### Scenario: Clear all filters +- **WHEN** user clicks "清除篩選" +- **THEN** all filter inputs SHALL be cleared +- **THEN** all active filter tags SHALL be removed + +#### Scenario: Query with no filters +- **WHEN** user clicks "查詢" with no filters +- **THEN** the query SHALL return the most recent 1000 rows (sorted by time_field if available) + +### Requirement: Tables page SHALL handle loading and error states +The page SHALL display appropriate feedback during API calls and on errors. + +#### Scenario: Loading state during column fetch +- **WHEN** column metadata is being fetched +- **THEN** the viewer SHALL display a loading indicator + +#### Scenario: Loading state during query +- **WHEN** a query is executing +- **THEN** the table body SHALL display a loading indicator + +#### Scenario: API error handling +- **WHEN** an API call fails +- **THEN** the page SHALL display the error message in the relevant area +- **THEN** the page SHALL NOT crash or become unresponsive + +#### Scenario: Empty query result +- **WHEN** a query returns zero rows +- **THEN** the table SHALL display a "查無資料" message + +### Requirement: Tables page SHALL allow closing the data viewer +The page SHALL allow users to close the data viewer and return to the catalog view. + +#### Scenario: Close data viewer +- **WHEN** user clicks the close button on the data viewer +- **THEN** the data viewer panel SHALL be hidden +- **THEN** all table cards SHALL return to inactive state +- **THEN** internal state (columns, filters) SHALL be reset diff --git a/openspec/changes/archive/2026-02-09-migrate-tables-vue/specs/vue-vite-page-architecture/spec.md b/openspec/changes/archive/2026-02-09-migrate-tables-vue/specs/vue-vite-page-architecture/spec.md new file mode 100644 index 0000000..511446f --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-tables-vue/specs/vue-vite-page-architecture/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Pure Vite pages SHALL handle POST API calls without legacy MesApi +Pure Vite pages SHALL use the `apiPost` function from `core/api.js` for POST requests without depending on `window.MesApi`. + +#### Scenario: API POST request from pure Vite page +- **WHEN** a pure Vite page makes a POST API call +- **THEN** the call SHALL use the `apiPost` function from `core/api.js` +- **THEN** the call SHALL include `Content-Type: application/json` header +- **THEN** the call SHALL work without `window.MesApi` being present + +#### Scenario: CSRF token handling in POST requests +- **WHEN** a pure Vite page calls `apiPost` +- **THEN** `apiPost` SHALL attempt to read CSRF token from `` +- **THEN** if no meta tag exists, the request SHALL still proceed (non-admin APIs do not enforce CSRF) + +## MODIFIED Requirements + +### Requirement: Vite config SHALL support Vue SFC and HTML entry points +The Vite build configuration SHALL support Vue Single File Components alongside existing vanilla JS entries. + +#### Scenario: Vue plugin coexistence +- **WHEN** `vite build` is executed +- **THEN** Vue SFC (`.vue` files) SHALL be compiled by `@vitejs/plugin-vue` +- **THEN** existing vanilla JS entry points SHALL continue to build without modification + +#### Scenario: HTML entry point +- **WHEN** a page uses an HTML file as its Vite entry point +- **THEN** Vite SHALL process the HTML and its referenced JS/CSS into `static/dist/` +- **THEN** the output SHALL include `.html`, `.js`, and `.css` + +#### Scenario: Chunk splitting +- **WHEN** Vite builds the project +- **THEN** Vue runtime SHALL be split into a `vendor-vue` chunk +- **THEN** ECharts modules SHALL be split into the existing `vendor-echarts` chunk +- **THEN** chunk splitting SHALL NOT affect existing page bundles + +#### Scenario: Migrated page entry replacement +- **WHEN** a vanilla JS page is migrated to Vue 3 +- **THEN** its Vite entry SHALL change from JS file to HTML file (e.g., `src/tables/main.js` → `src/tables/index.html`) +- **THEN** the original JS entry SHALL be replaced, not kept alongside diff --git a/openspec/changes/archive/2026-02-09-migrate-tables-vue/tasks.md b/openspec/changes/archive/2026-02-09-migrate-tables-vue/tasks.md new file mode 100644 index 0000000..3015f0d --- /dev/null +++ b/openspec/changes/archive/2026-02-09-migrate-tables-vue/tasks.md @@ -0,0 +1,26 @@ +## 1. Vue 3 前端結構建立 + +- [x] 1.1 建立 `frontend/src/tables/index.html` — 純 Vite HTML entry point(參照 qc-gate 模式) +- [x] 1.2 重寫 `frontend/src/tables/main.js` — Vue 3 createApp bootstrap(取代原 237 行 vanilla JS) +- [x] 1.3 建立 `frontend/src/tables/style.css` — 從 Jinja2 模板提取核心樣式 + +## 2. Vue 元件開發 + +- [x] 2.1 建立 `frontend/src/tables/App.vue` — 根元件,管理 loading/error 全局狀態與佈局 +- [x] 2.2 建立 `frontend/src/tables/components/TableCatalog.vue` — 表格卡片目錄(分類顯示、大表 badge、active 狀態) +- [x] 2.3 建立 `frontend/src/tables/components/DataViewer.vue` — 資料檢視器(欄位篩選輸入、查詢結果表、filter tag、close) + +## 3. Composable 與 API 整合 + +- [x] 3.1 建立 `frontend/src/tables/composables/useTableData.js` — 封裝 apiGet/apiPost 呼叫、table config/columns/query 狀態管理 + +## 4. Vite 與 Flask 路由整合 + +- [x] 4.1 更新 `frontend/vite.config.js` — tables entry 從 `main.js` 改為 `index.html` +- [x] 4.2 更新 `src/mes_dashboard/app.py` — `/tables` route 改為 `send_from_directory` + +## 5. 清理與驗證 + +- [x] 5.1 移除 Jinja2 模板 `src/mes_dashboard/templates/index.html` +- [x] 5.2 移除 `app.py` 中 `/tables` route 的 `TABLES_CONFIG` import(如不再被其他地方使用) +- [x] 5.3 執行 `npm run build` 驗證建置成功,確認 `static/dist/tables.html` 產出 diff --git a/openspec/specs/tables-query-page/spec.md b/openspec/specs/tables-query-page/spec.md new file mode 100644 index 0000000..9d2096a --- /dev/null +++ b/openspec/specs/tables-query-page/spec.md @@ -0,0 +1,88 @@ +## Purpose +Define stable requirements for tables-query-page. + +## Requirements + + +### Requirement: Tables page SHALL display categorized table catalog +The page SHALL display all configured DWH tables as clickable cards, grouped by category. + +#### Scenario: Table catalog rendering +- **WHEN** the page loads +- **THEN** the page SHALL fetch table configuration from `GET /api/get_table_info` +- **THEN** tables SHALL be displayed as cards grouped by category (即時數據表, 現況快照表, 歷史累積表, 輔助表) +- **THEN** each card SHALL show the table display name and description + +#### Scenario: Large table badge +- **WHEN** a table has `row_count` exceeding 10,000,000 +- **THEN** the card SHALL display a visual indicator (badge) marking it as a large table + +### Requirement: Tables page SHALL load column metadata on table selection +The page SHALL load and display column information when a table is selected from the catalog. + +#### Scenario: Select table from catalog +- **WHEN** user clicks a table card +- **THEN** the page SHALL call `POST /api/get_table_columns` with the table name +- **THEN** the data viewer panel SHALL open showing the table name and column count +- **THEN** a filter input row SHALL appear with one input per column + +#### Scenario: Active table indication +- **WHEN** a table is selected +- **THEN** the selected card SHALL have a visual active state +- **THEN** previously active cards SHALL be deactivated + +### Requirement: Tables page SHALL support column-level filtering +The page SHALL allow users to enter filter values per column and query the table data. + +#### Scenario: Enter filter and query +- **WHEN** user enters filter values in column inputs and clicks "查詢" +- **THEN** the page SHALL call `POST /api/query_table` with the table name, filters, limit (1000), and time_field +- **THEN** the result table SHALL display returned rows with column headers +- **THEN** the title SHALL show the table name, row count, and active filter count + +#### Scenario: Enter key triggers query +- **WHEN** user presses Enter in any filter input +- **THEN** the query SHALL execute as if the "查詢" button was clicked + +#### Scenario: Active filter display +- **WHEN** filters are applied +- **THEN** active filters SHALL be displayed as removable tags above the result table +- **THEN** clicking a tag's remove button SHALL clear that filter + +#### Scenario: Clear all filters +- **WHEN** user clicks "清除篩選" +- **THEN** all filter inputs SHALL be cleared +- **THEN** all active filter tags SHALL be removed + +#### Scenario: Query with no filters +- **WHEN** user clicks "查詢" with no filters +- **THEN** the query SHALL return the most recent 1000 rows (sorted by time_field if available) + +### Requirement: Tables page SHALL handle loading and error states +The page SHALL display appropriate feedback during API calls and on errors. + +#### Scenario: Loading state during column fetch +- **WHEN** column metadata is being fetched +- **THEN** the viewer SHALL display a loading indicator + +#### Scenario: Loading state during query +- **WHEN** a query is executing +- **THEN** the table body SHALL display a loading indicator + +#### Scenario: API error handling +- **WHEN** an API call fails +- **THEN** the page SHALL display the error message in the relevant area +- **THEN** the page SHALL NOT crash or become unresponsive + +#### Scenario: Empty query result +- **WHEN** a query returns zero rows +- **THEN** the table SHALL display a "查無資料" message + +### Requirement: Tables page SHALL allow closing the data viewer +The page SHALL allow users to close the data viewer and return to the catalog view. + +#### Scenario: Close data viewer +- **WHEN** user clicks the close button on the data viewer +- **THEN** the data viewer panel SHALL be hidden +- **THEN** all table cards SHALL return to inactive state +- **THEN** internal state (columns, filters) SHALL be reset diff --git a/openspec/specs/vue-vite-page-architecture/spec.md b/openspec/specs/vue-vite-page-architecture/spec.md index b830689..7d71362 100644 --- a/openspec/specs/vue-vite-page-architecture/spec.md +++ b/openspec/specs/vue-vite-page-architecture/spec.md @@ -36,6 +36,11 @@ The Vite build configuration SHALL support Vue Single File Components alongside - **THEN** ECharts modules SHALL be split into the existing `vendor-echarts` chunk - **THEN** chunk splitting SHALL NOT affect existing page bundles +#### Scenario: Migrated page entry replacement +- **WHEN** a vanilla JS page is migrated to Vue 3 +- **THEN** its Vite entry SHALL change from JS file to HTML file (e.g., `src/tables/main.js` → `src/tables/index.html`) +- **THEN** the original JS entry SHALL be replaced, not kept alongside + ### Requirement: Pure Vite pages SHALL handle API calls without legacy MesApi Pure Vite pages SHALL use the existing `frontend/src/core/api.js` module for API communication without depending on the global `window.MesApi` object from `_base.html`. @@ -43,3 +48,17 @@ Pure Vite pages SHALL use the existing `frontend/src/core/api.js` module for API - **WHEN** a pure Vite page makes a GET API call - **THEN** the call SHALL use the `apiGet` function from `core/api.js` - **THEN** the call SHALL work without `window.MesApi` being present + +### Requirement: Pure Vite pages SHALL handle POST API calls without legacy MesApi +Pure Vite pages SHALL use the `apiPost` function from `core/api.js` for POST requests without depending on `window.MesApi`. + +#### Scenario: API POST request from pure Vite page +- **WHEN** a pure Vite page makes a POST API call +- **THEN** the call SHALL use the `apiPost` function from `core/api.js` +- **THEN** the call SHALL include `Content-Type: application/json` header +- **THEN** the call SHALL work without `window.MesApi` being present + +#### Scenario: CSRF token handling in POST requests +- **WHEN** a pure Vite page calls `apiPost` +- **THEN** `apiPost` SHALL attempt to read CSRF token from `` +- **THEN** if no meta tag exists, the request SHALL still proceed (non-admin APIs do not enforce CSRF) diff --git a/src/mes_dashboard/app.py b/src/mes_dashboard/app.py index e1c6295..9de3981 100644 --- a/src/mes_dashboard/app.py +++ b/src/mes_dashboard/app.py @@ -11,7 +11,6 @@ import threading from flask import Flask, jsonify, redirect, render_template, request, send_from_directory, session, url_for -from mes_dashboard.config.tables import TABLES_CONFIG from mes_dashboard.config.settings import get_config from mes_dashboard.core.cache import create_default_cache_backend from mes_dashboard.core.database import ( @@ -380,8 +379,26 @@ def create_app(config_name: str | None = None) -> Flask: @app.route('/tables') def tables_page(): - """Table viewer page.""" - return render_template('index.html', tables_config=TABLES_CONFIG) + """Table viewer page served as pure Vite HTML output.""" + dist_dir = os.path.join(app.static_folder or "", "dist") + dist_html = os.path.join(dist_dir, "tables.html") + if os.path.exists(dist_html): + return send_from_directory(dist_dir, 'tables.html') + + nested_dist_dir = os.path.join(dist_dir, "src", "tables") + nested_dist_html = os.path.join(nested_dist_dir, "index.html") + if os.path.exists(nested_dist_html): + return send_from_directory(nested_dist_dir, "index.html") + + # Test/local fallback when frontend build artifacts are absent. + return ( + "" + "" + "MES 數據表查詢工具" + "" + "
", + 200, + ) @app.route('/wip-overview') def wip_overview_page(): @@ -453,6 +470,8 @@ def create_app(config_name: str | None = None) -> Flask: @app.route('/api/get_table_info', methods=['GET']) def get_table_info(): """API: get tables config.""" + from mes_dashboard.config.tables import TABLES_CONFIG + return jsonify(TABLES_CONFIG) # ======================================================== diff --git a/src/mes_dashboard/services/sql_fragments.py b/src/mes_dashboard/services/sql_fragments.py index a364b07..5e2f153 100644 --- a/src/mes_dashboard/services/sql_fragments.py +++ b/src/mes_dashboard/services/sql_fragments.py @@ -8,9 +8,14 @@ resource/equipment cache implementations. from __future__ import annotations RESOURCE_TABLE = "DWH.DW_MES_RESOURCE" -RESOURCE_BASE_SELECT_TEMPLATE = f"SELECT * FROM {RESOURCE_TABLE} {{ WHERE_CLAUSE }}" +# NOTE: +# QueryBuilder.build() only replaces the exact token "{{ WHERE_CLAUSE }}". +# Keep this token literal (double braces) in shared SQL templates. +RESOURCE_BASE_SELECT_TEMPLATE = ( + f"SELECT * FROM {RESOURCE_TABLE} {{{{ WHERE_CLAUSE }}}}" +) RESOURCE_VERSION_SELECT_TEMPLATE = ( - f"SELECT MAX(LASTCHANGEDATE) as VERSION FROM {RESOURCE_TABLE} {{ WHERE_CLAUSE }}" + f"SELECT MAX(LASTCHANGEDATE) as VERSION FROM {RESOURCE_TABLE} {{{{ WHERE_CLAUSE }}}}" ) EQUIPMENT_STATUS_VIEW = "DWH.DW_MES_EQUIPMENTSTATUS_WIP_V" diff --git a/src/mes_dashboard/templates/index.html b/src/mes_dashboard/templates/index.html deleted file mode 100644 index f7665fb..0000000 --- a/src/mes_dashboard/templates/index.html +++ /dev/null @@ -1,589 +0,0 @@ -{% extends "_base.html" %} - -{% block title %}MES 數據表查詢工具{% endblock %} - -{% block head_extra %} - -{% endblock %} - -{% block content %} -
-
-

MES 數據表查詢工具

-

點擊表名載入欄位 | 輸入篩選條件後查詢 | 套用篩選後取最後 1000 筆

-
- -
- {% for category, tables in tables_config.items() %} -
-
{{ category }}
-
- {% for table in tables %} -
-
- {{ table.display_name }} - {% if table.row_count > 10000000 %} - 大表 - {% endif %} -
-
數據量: {{ "{:,}".format(table.row_count) }} 行
- {% if table.time_field %} -
時間欄位: {{ table.time_field }}
- {% endif %} -
{{ table.description }}
-
- {% endfor %} -
-
- {% endfor %} - -
-
-

數據查看器

- -
-
-
-
-
-
-
-
-{% endblock %} - -{% block scripts %} - {% set tables_js = frontend_asset('tables.js') %} - {% if tables_js %} - - {% else %} - - {% endif %} -{% endblock %} diff --git a/tests/test_resource_cache.py b/tests/test_resource_cache.py index 5b2487c..8ac5263 100644 --- a/tests/test_resource_cache.py +++ b/tests/test_resource_cache.py @@ -507,6 +507,24 @@ class TestBuildFilterBuilder: sql = mock_read.call_args[0][0] assert RESOURCE_TABLE in sql + def test_resource_version_sql_replaces_where_clause_placeholder(self): + """Version SQL should not leak placeholder token into Oracle.""" + import mes_dashboard.services.resource_cache as rc + from mes_dashboard.services.sql_fragments import RESOURCE_TABLE + + with patch.object( + rc, + "read_sql_df", + return_value=pd.DataFrame([{"VERSION": "2026-02-09T12:00:00"}]), + ) as mock_read: + rc._get_version_from_oracle() + + sql = mock_read.call_args[0][0] + assert RESOURCE_TABLE in sql + assert "{{ WHERE_CLAUSE }}" not in sql + assert "{ WHERE_CLAUSE }" not in sql + assert "WHERE " in sql + class TestResourceDerivedIndex: """Test derived resource index and telemetry behavior.""" diff --git a/tests/test_template_integration.py b/tests/test_template_integration.py index 3c7a246..cc6e9df 100644 --- a/tests/test_template_integration.py +++ b/tests/test_template_integration.py @@ -54,14 +54,14 @@ class TestTemplateIntegration(unittest.TestCase): self.assertIn('mes-api.js', html) self.assertIn('mes-toast-container', html) - def test_tables_page_includes_base_scripts(self): + def test_tables_page_serves_pure_vite_module(self): response = self.client.get('/tables') self.assertEqual(response.status_code, 200) html = response.data.decode('utf-8') - self.assertIn('toast.js', html) - self.assertIn('mes-api.js', html) - self.assertIn('mes-toast-container', html) + self.assertIn('/static/dist/tables.js', html) + self.assertIn('type="module"', html) + self.assertNotIn('mes-toast-container', html) def test_resource_page_includes_base_scripts(self): response = self.client.get('/resource')