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 <noreply@anthropic.com>
This commit is contained in:
31
README.md
31
README.md
@@ -36,11 +36,15 @@
|
|||||||
| 部署自動化 | ✅ 已完成 |
|
| 部署自動化 | ✅ 已完成 |
|
||||||
| Portal 動態抽屜導覽管理 | ✅ 已完成 |
|
| Portal 動態抽屜導覽管理 | ✅ 已完成 |
|
||||||
| QC-GATE 即時狀態報表(Vue 3 + Vite) | ✅ 已完成 |
|
| QC-GATE 即時狀態報表(Vue 3 + Vite) | ✅ 已完成 |
|
||||||
|
| 數據表查詢頁面 Vue 3 遷移 | ✅ 已完成 |
|
||||||
|
| 設備快取 DataFrame TTL 一致性修復 | ✅ 已完成 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 開發歷史(Vite 重構後)
|
## 開發歷史(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:新增 QC-GATE 即時狀態報表 — 第一個純 Vue 3 + Vite 頁面(脫離 Jinja2),建立後續前端遷移架構模式。
|
||||||
- 2026-02-09:完成 Portal 動態抽屜導覽管理,sidebar drawer/page 配置改為 admin 可管理。
|
- 2026-02-09:完成 Portal 動態抽屜導覽管理,sidebar drawer/page 配置改為 admin 可管理。
|
||||||
- 2026-02-07:完成 Flask + Vite 單一 port 架構切換,舊版 `DashBoard/` 停用。
|
- 2026-02-07:完成 Flask + Vite 單一 port 架構切換,舊版 `DashBoard/` 停用。
|
||||||
@@ -450,9 +454,9 @@ A: 請確認瀏覽器允許下載檔案,並檢查查詢結果是否有資料
|
|||||||
### Portal 入口頁面
|
### Portal 入口頁面
|
||||||
|
|
||||||
透過側邊欄抽屜分組導覽切換各功能模組:
|
透過側邊欄抽屜分組導覽切換各功能模組:
|
||||||
- **報表類**:WIP 即時概況、QC-GATE 即時狀態
|
- **報表類**:WIP 即時概況、設備即時概況、設備歷史績效、QC-GATE 即時狀態
|
||||||
- **查詢類**:WIP 明細查詢、Hold 狀態分析、設備狀態監控、設備歷史查詢、數據表查詢工具
|
- **查詢類**:設備維修查詢、批次追蹤工具、TMTT 不良分析
|
||||||
- **開發工具**(admin only):Excel 批次查詢、TMTT 不良分析等
|
- **開發工具**(admin only):數據表查詢、Excel 批次查詢、頁面管理、效能監控
|
||||||
- 抽屜/頁面配置可由管理員動態管理(新增、重排、刪除)
|
- 抽屜/頁面配置可由管理員動態管理(新增、重排、刪除)
|
||||||
|
|
||||||
### WIP 即時概況
|
### WIP 即時概況
|
||||||
@@ -502,6 +506,15 @@ A: 請確認瀏覽器允許下載檔案,並檢查查詢結果是否有資料
|
|||||||
- 站點排序依 DW_MES_SPEC_WORKCENTER_V 製程順序
|
- 站點排序依 DW_MES_SPEC_WORKCENTER_V 製程順序
|
||||||
- **技術架構**:第一個純 Vue 3 + Vite 頁面,完全脫離 Jinja2
|
- **技術架構**:第一個純 Vue 3 + Vite 頁面,完全脫離 Jinja2
|
||||||
|
|
||||||
|
### 數據表查詢工具
|
||||||
|
|
||||||
|
- 顯示所有 DWH 表格的分類卡片目錄(即時數據表/現況快照表/歷史累積表/輔助表)
|
||||||
|
- 大表標記:超過 1,000 萬筆資料的表格顯示 badge 提示
|
||||||
|
- 選擇表格後自動載入欄位資訊,每欄提供篩選輸入
|
||||||
|
- 支援 Enter 鍵或「查詢」按鈕觸發查詢(預設回傳最近 1000 筆)
|
||||||
|
- 使用中的篩選條件顯示為可移除的 tag,支援一鍵清除全部
|
||||||
|
- **技術架構**:第二個純 Vue 3 + Vite 頁面,使用 `apiPost` 建立 POST 請求模式
|
||||||
|
|
||||||
### 管理員功能
|
### 管理員功能
|
||||||
|
|
||||||
- LDAP 認證登入(支援本地測試模式)
|
- LDAP 認證登入(支援本地測試模式)
|
||||||
@@ -705,6 +718,16 @@ conda run -n mes-dashboard python scripts/run_cache_benchmarks.py --enforce
|
|||||||
|
|
||||||
### 2026-02-09
|
### 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`):
|
- 新增 QC-GATE 即時狀態報表頁面(`/qc-gate`):
|
||||||
- 第一個純 Vue 3 + Vite 頁面,完全脫離 Jinja2 模板
|
- 第一個純 Vue 3 + Vite 頁面,完全脫離 Jinja2 模板
|
||||||
- ECharts 堆疊條圖顯示各 QC-GATE 站點 LOT 分佈(按 6hr 時間分級)
|
- 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
|
**最後更新**: 2026-02-09
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"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"
|
"test": "node --test tests/*.test.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
123
frontend/src/tables/App.vue
Normal file
123
frontend/src/tables/App.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
|
||||||
|
import TableCatalog from './components/TableCatalog.vue';
|
||||||
|
import DataViewer from './components/DataViewer.vue';
|
||||||
|
import { useTableData } from './composables/useTableData.js';
|
||||||
|
|
||||||
|
const {
|
||||||
|
tableConfig,
|
||||||
|
selectedTable,
|
||||||
|
columns,
|
||||||
|
filters,
|
||||||
|
rows,
|
||||||
|
rowCount,
|
||||||
|
hasQueried,
|
||||||
|
loadingConfig,
|
||||||
|
loadingColumns,
|
||||||
|
loadingQuery,
|
||||||
|
pageError,
|
||||||
|
viewerError,
|
||||||
|
activeFilterCount,
|
||||||
|
loadTableConfig,
|
||||||
|
selectTable,
|
||||||
|
setFilter,
|
||||||
|
removeFilter,
|
||||||
|
clearFilters,
|
||||||
|
queryTable,
|
||||||
|
closeViewer,
|
||||||
|
} = useTableData();
|
||||||
|
|
||||||
|
const categories = computed(() => {
|
||||||
|
return Object.entries(tableConfig.value || {}).map(([name, tables]) => ({
|
||||||
|
name,
|
||||||
|
tables: Array.isArray(tables) ? tables : [],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const isInitialLoading = computed(() => {
|
||||||
|
return loadingConfig.value && categories.value.length === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isCatalogEmpty = computed(() => {
|
||||||
|
return !loadingConfig.value && !pageError.value && categories.value.length === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSelectTable(table) {
|
||||||
|
void selectTable(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuery() {
|
||||||
|
void queryTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefreshCatalog() {
|
||||||
|
void loadTableConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadTableConfig();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tables-page">
|
||||||
|
<div class="container">
|
||||||
|
<header class="header">
|
||||||
|
<div>
|
||||||
|
<h1>MES 數據表查詢工具</h1>
|
||||||
|
<p>點擊表名載入欄位,輸入篩選條件後查詢,最多返回最後 1000 筆資料</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="refresh-catalog-btn"
|
||||||
|
:disabled="loadingConfig"
|
||||||
|
@click="handleRefreshCatalog"
|
||||||
|
>
|
||||||
|
{{ loadingConfig ? '載入中...' : '重新載入清單' }}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<div v-if="pageError" class="error-banner">
|
||||||
|
{{ pageError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isInitialLoading" class="loading-panel">
|
||||||
|
正在載入表格設定...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="isCatalogEmpty" class="empty-state">
|
||||||
|
尚無可用表格設定
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TableCatalog
|
||||||
|
v-else
|
||||||
|
:categories="categories"
|
||||||
|
:selected-table-name="selectedTable?.name || ''"
|
||||||
|
:disabled="loadingColumns || loadingQuery"
|
||||||
|
@select-table="handleSelectTable"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataViewer
|
||||||
|
v-if="selectedTable"
|
||||||
|
:selected-table="selectedTable"
|
||||||
|
:columns="columns"
|
||||||
|
:filters="filters"
|
||||||
|
:rows="rows"
|
||||||
|
:row-count="rowCount"
|
||||||
|
:active-filter-count="activeFilterCount"
|
||||||
|
:has-queried="hasQueried"
|
||||||
|
:loading-columns="loadingColumns"
|
||||||
|
:loading-query="loadingQuery"
|
||||||
|
:error-message="viewerError"
|
||||||
|
@close="closeViewer"
|
||||||
|
@query="handleQuery"
|
||||||
|
@set-filter="setFilter"
|
||||||
|
@remove-filter="removeFilter"
|
||||||
|
@clear-filters="clearFilters"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
181
frontend/src/tables/components/DataViewer.vue
Normal file
181
frontend/src/tables/components/DataViewer.vue
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
selectedTable: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
rowCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
activeFilterCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
hasQueried: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
loadingColumns: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
loadingQuery: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'query', 'set-filter', 'remove-filter', 'clear-filters']);
|
||||||
|
|
||||||
|
const activeFilters = computed(() => {
|
||||||
|
const entries = Object.entries(props.filters || {});
|
||||||
|
return entries
|
||||||
|
.filter(([, value]) => String(value ?? '').trim().length > 0)
|
||||||
|
.sort(([left], [right]) => {
|
||||||
|
return props.columns.indexOf(left) - props.columns.indexOf(right);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewerTitle = computed(() => {
|
||||||
|
const displayName = props.selectedTable?.display_name || props.selectedTable?.name || '--';
|
||||||
|
if (props.loadingColumns) {
|
||||||
|
return `正在載入: ${displayName}`;
|
||||||
|
}
|
||||||
|
if (props.loadingQuery) {
|
||||||
|
return `正在查詢: ${displayName}`;
|
||||||
|
}
|
||||||
|
if (props.hasQueried) {
|
||||||
|
const suffix = props.activeFilterCount > 0 ? ` [${props.activeFilterCount} 個篩選]` : '';
|
||||||
|
return `${displayName} (${props.rowCount} 筆)${suffix}`;
|
||||||
|
}
|
||||||
|
return `${displayName} (${props.columns.length} 欄位)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onFilterInput(column, event) {
|
||||||
|
emit('set-filter', column, event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterEnter() {
|
||||||
|
emit('query');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNil(value) {
|
||||||
|
return value === null || value === undefined;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="data-viewer active">
|
||||||
|
<div class="viewer-header">
|
||||||
|
<h3>{{ viewerTitle }}</h3>
|
||||||
|
<button type="button" class="close-btn" @click="$emit('close')">關閉</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">表名</div>
|
||||||
|
<div class="stat-value stat-value-table-name">{{ selectedTable.name }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">欄位數</div>
|
||||||
|
<div class="stat-value">{{ columns.length }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="filter-hint">在下方輸入框填入篩選條件 (模糊匹配)</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="query-btn"
|
||||||
|
:disabled="loadingColumns || loadingQuery || columns.length === 0"
|
||||||
|
@click="$emit('query')"
|
||||||
|
>
|
||||||
|
查詢
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="clear-btn"
|
||||||
|
:disabled="loadingColumns || loadingQuery || columns.length === 0"
|
||||||
|
@click="$emit('clear-filters')"
|
||||||
|
>
|
||||||
|
清除篩選
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="activeFilters.length > 0" class="active-filters">
|
||||||
|
<span
|
||||||
|
v-for="[column, value] in activeFilters"
|
||||||
|
:key="column"
|
||||||
|
class="filter-tag"
|
||||||
|
>
|
||||||
|
{{ column }}: {{ value }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove"
|
||||||
|
@click="$emit('remove-filter', column)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<div v-if="loadingColumns" class="loading">正在載入欄位資訊...</div>
|
||||||
|
<div v-else-if="errorMessage" class="error">{{ errorMessage }}</div>
|
||||||
|
<div v-else-if="columns.length === 0" class="empty-hint">尚未載入欄位資訊</div>
|
||||||
|
<table v-else>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-for="column in columns" :key="`head-${column}`">{{ column }}</th>
|
||||||
|
</tr>
|
||||||
|
<tr class="filter-row">
|
||||||
|
<th v-for="column in columns" :key="`filter-${column}`">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="篩選..."
|
||||||
|
:value="filters[column] || ''"
|
||||||
|
@input="onFilterInput(column, $event)"
|
||||||
|
@keydown.enter.prevent="onFilterEnter"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="loadingQuery">
|
||||||
|
<td :colspan="columns.length" class="loading">正在查詢資料...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="!hasQueried">
|
||||||
|
<td :colspan="columns.length" class="empty-hint">
|
||||||
|
請輸入篩選條件後點擊「查詢」,或直接點擊「查詢」載入最後 1000 筆資料
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="rows.length === 0">
|
||||||
|
<td :colspan="columns.length" class="empty-hint">查無資料</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="(row, rowIndex) in rows" v-else :key="`row-${rowIndex}`">
|
||||||
|
<td v-for="column in columns" :key="`cell-${rowIndex}-${column}`">
|
||||||
|
<i v-if="isNil(row[column])" class="null-value">NULL</i>
|
||||||
|
<span v-else>{{ row[column] }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
73
frontend/src/tables/components/TableCatalog.vue
Normal file
73
frontend/src/tables/components/TableCatalog.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
categories: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
selectedTableName: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['select-table']);
|
||||||
|
|
||||||
|
function formatRowCount(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
return parsed.toLocaleString('zh-TW');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDisplayName(table) {
|
||||||
|
return table?.display_name || table?.name || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(table) {
|
||||||
|
if (props.disabled || !table?.name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('select-table', table);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="table-catalog">
|
||||||
|
<div
|
||||||
|
v-for="category in categories"
|
||||||
|
:key="category.name"
|
||||||
|
class="table-category"
|
||||||
|
>
|
||||||
|
<h2 class="category-title">{{ category.name }}</h2>
|
||||||
|
<div class="table-grid">
|
||||||
|
<article
|
||||||
|
v-for="table in category.tables"
|
||||||
|
:key="table.name"
|
||||||
|
class="table-card"
|
||||||
|
:class="{
|
||||||
|
active: table.name === selectedTableName,
|
||||||
|
disabled,
|
||||||
|
}"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="handleSelect(table)"
|
||||||
|
@keydown.enter.prevent="handleSelect(table)"
|
||||||
|
@keydown.space.prevent="handleSelect(table)"
|
||||||
|
>
|
||||||
|
<h3 class="table-name">
|
||||||
|
{{ resolveDisplayName(table) }}
|
||||||
|
<span v-if="Number(table.row_count || 0) > 10000000" class="badge large">大表</span>
|
||||||
|
</h3>
|
||||||
|
<p class="table-info">數據量: {{ formatRowCount(table.row_count) }} 行</p>
|
||||||
|
<p v-if="table.time_field" class="table-info">時間欄位: {{ table.time_field }}</p>
|
||||||
|
<p class="table-desc">{{ table.description || '無描述' }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
200
frontend/src/tables/composables/useTableData.js
Normal file
200
frontend/src/tables/composables/useTableData.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
12
frontend/src/tables/index.html
Normal file
12
frontend/src/tables/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-Hant">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MES 數據表查詢工具</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="./main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,236 +1,6 @@
|
|||||||
import { ensureMesApiAvailable } from '../core/api.js';
|
import { createApp } from 'vue';
|
||||||
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';
|
|
||||||
|
|
||||||
ensureMesApiAvailable();
|
import App from './App.vue';
|
||||||
window.__MES_FRONTEND_CORE__ = { buildResourceKpiFromHours, groupBy, sortBy, toggleTreeState, setTreeStateBulk, escapeHtml, safeText };
|
import './style.css';
|
||||||
window.__FIELD_CONTRACTS__ = window.__FIELD_CONTRACTS__ || {};
|
|
||||||
window.__FIELD_CONTRACTS__['tables:result_table'] = getPageContract('tables', 'result_table');
|
|
||||||
|
|
||||||
|
createApp(App).mount('#app');
|
||||||
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 = '<div class="loading">正在載入欄位資訊...</div>';
|
|
||||||
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 = `<div class="error">${escapeHtml(data.error)}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentColumns = data.columns;
|
|
||||||
title.textContent = `${displayName} (${currentColumns.length} 欄位)`;
|
|
||||||
|
|
||||||
renderFilterControls();
|
|
||||||
} catch (error) {
|
|
||||||
content.innerHTML = `<div class="error">請求失敗: ${escapeHtml(error.message)}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFilterControls() {
|
|
||||||
const statsContainer = document.getElementById('statsContainer');
|
|
||||||
const content = document.getElementById('tableContent');
|
|
||||||
|
|
||||||
statsContainer.innerHTML = `
|
|
||||||
<div class="stats">
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-label">表名</div>
|
|
||||||
<div class="stat-value" style="font-size: 14px;">${escapeHtml(currentTable)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-label">欄位數</div>
|
|
||||||
<div class="stat-value">${currentColumns.length}</div>
|
|
||||||
</div>
|
|
||||||
<span class="filter-hint">在下方輸入框填入篩選條件 (模糊匹配)</span>
|
|
||||||
<button class="query-btn" onclick="executeQuery()">查詢</button>
|
|
||||||
<button class="clear-btn" onclick="clearFilters()">清除篩選</button>
|
|
||||||
</div>
|
|
||||||
<div id="activeFilters" class="active-filters"></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
let html = '<table><thead>';
|
|
||||||
html += '<tr>';
|
|
||||||
currentColumns.forEach(col => {
|
|
||||||
html += `<th>${escapeHtml(col)}</th>`;
|
|
||||||
});
|
|
||||||
html += '</tr>';
|
|
||||||
|
|
||||||
html += '<tr class="filter-row">';
|
|
||||||
currentColumns.forEach(col => {
|
|
||||||
const filterId = toFilterInputId(col);
|
|
||||||
const jsCol = toJsSingleQuoted(col);
|
|
||||||
html += `<th><input type="text" id="${filterId}" placeholder="篩選..." onkeypress="handleFilterKeypress(event)" onchange="updateFilter('${jsCol}', this.value)"></th>`;
|
|
||||||
});
|
|
||||||
html += '</tr>';
|
|
||||||
|
|
||||||
html += '</thead><tbody id="dataBody">';
|
|
||||||
html += '<tr><td colspan="' + currentColumns.length + '" style="text-align: center; padding: 40px; color: #666;">請輸入篩選條件後點擊「查詢」,或直接點擊「查詢」載入最後 1000 筆資料</td></tr>';
|
|
||||||
html += '</tbody></table>';
|
|
||||||
|
|
||||||
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 += `<span class="filter-tag">${escapeHtml(col)}: ${escapeHtml(currentFilters[col])} <span class="remove" onclick="removeFilter('${toJsSingleQuoted(col)}')">×</span></span>`;
|
|
||||||
});
|
|
||||||
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 = `<tr><td colspan="${currentColumns.length}" class="loading">正在查詢資料...</td></tr>`;
|
|
||||||
|
|
||||||
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 = `<tr><td colspan="${currentColumns.length}" class="error">${escapeHtml(data.error)}</td></tr>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterCount = Object.keys(currentFilters).length;
|
|
||||||
const filterText = filterCount > 0 ? ` [${filterCount} 個篩選]` : '';
|
|
||||||
title.textContent = `${currentDisplayName} (${data.row_count} 筆)${filterText}`;
|
|
||||||
|
|
||||||
if (data.data.length === 0) {
|
|
||||||
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" style="text-align: center; padding: 40px; color: #999;">查無資料</td></tr>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
data.data.forEach(row => {
|
|
||||||
html += '<tr>';
|
|
||||||
currentColumns.forEach(col => {
|
|
||||||
const value = row[col];
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
html += '<td><i style="color: #999;">NULL</i></td>';
|
|
||||||
} else {
|
|
||||||
html += `<td>${escapeHtml(safeText(value))}</td>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
html += '</tr>';
|
|
||||||
});
|
|
||||||
tbody.innerHTML = html;
|
|
||||||
} catch (error) {
|
|
||||||
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" class="error">請求失敗: ${escapeHtml(error.message)}</td></tr>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|||||||
457
frontend/src/tables/style.css
Normal file
457
frontend/src/tables/style.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export default defineConfig(({ mode }) => ({
|
|||||||
'resource-history': resolve(__dirname, 'src/resource-history/main.js'),
|
'resource-history': resolve(__dirname, 'src/resource-history/main.js'),
|
||||||
'job-query': resolve(__dirname, 'src/job-query/main.js'),
|
'job-query': resolve(__dirname, 'src/job-query/main.js'),
|
||||||
'excel-query': resolve(__dirname, 'src/excel-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'),
|
'query-tool': resolve(__dirname, 'src/query-tool/main.js'),
|
||||||
'tmtt-defect': resolve(__dirname, 'src/tmtt-defect/main.js'),
|
'tmtt-defect': resolve(__dirname, 'src/tmtt-defect/main.js'),
|
||||||
'qc-gate': resolve(__dirname, 'src/qc-gate/index.html')
|
'qc-gate': resolve(__dirname, 'src/qc-gate/index.html')
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-02-09
|
||||||
@@ -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>` 讀取),即使沒有 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 風格統一。
|
||||||
@@ -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(透過 `<meta>` 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` 不變
|
||||||
@@ -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
|
||||||
@@ -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 `<meta name="csrf-token">`
|
||||||
|
- **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 `<page-name>.html`, `<page-name>.js`, and `<page-name>.css`
|
||||||
|
|
||||||
|
#### Scenario: Chunk splitting
|
||||||
|
- **WHEN** Vite builds the project
|
||||||
|
- **THEN** Vue runtime SHALL be split into a `vendor-vue` chunk
|
||||||
|
- **THEN** ECharts modules SHALL be split into the existing `vendor-echarts` chunk
|
||||||
|
- **THEN** chunk splitting SHALL NOT affect existing page bundles
|
||||||
|
|
||||||
|
#### 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
|
||||||
@@ -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` 產出
|
||||||
88
openspec/specs/tables-query-page/spec.md
Normal file
88
openspec/specs/tables-query-page/spec.md
Normal file
@@ -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
|
||||||
@@ -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** ECharts modules SHALL be split into the existing `vendor-echarts` chunk
|
||||||
- **THEN** chunk splitting SHALL NOT affect existing page bundles
|
- **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
|
### 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`.
|
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
|
- **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 use the `apiGet` function from `core/api.js`
|
||||||
- **THEN** the call SHALL work without `window.MesApi` being present
|
- **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 `<meta name="csrf-token">`
|
||||||
|
- **THEN** if no meta tag exists, the request SHALL still proceed (non-admin APIs do not enforce CSRF)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import threading
|
|||||||
|
|
||||||
from flask import Flask, jsonify, redirect, render_template, request, send_from_directory, session, url_for
|
from flask import Flask, jsonify, redirect, render_template, request, send_from_directory, session, url_for
|
||||||
|
|
||||||
from mes_dashboard.config.tables import TABLES_CONFIG
|
|
||||||
from mes_dashboard.config.settings import get_config
|
from mes_dashboard.config.settings import get_config
|
||||||
from mes_dashboard.core.cache import create_default_cache_backend
|
from mes_dashboard.core.cache import create_default_cache_backend
|
||||||
from mes_dashboard.core.database import (
|
from mes_dashboard.core.database import (
|
||||||
@@ -380,8 +379,26 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||||||
|
|
||||||
@app.route('/tables')
|
@app.route('/tables')
|
||||||
def tables_page():
|
def tables_page():
|
||||||
"""Table viewer page."""
|
"""Table viewer page served as pure Vite HTML output."""
|
||||||
return render_template('index.html', tables_config=TABLES_CONFIG)
|
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 (
|
||||||
|
"<!doctype html><html lang=\"zh-Hant\"><head><meta charset=\"UTF-8\">"
|
||||||
|
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
|
||||||
|
"<title>MES 數據表查詢工具</title>"
|
||||||
|
"<script type=\"module\" src=\"/static/dist/tables.js\"></script>"
|
||||||
|
"</head><body><div id='app'></div></body></html>",
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/wip-overview')
|
@app.route('/wip-overview')
|
||||||
def wip_overview_page():
|
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'])
|
@app.route('/api/get_table_info', methods=['GET'])
|
||||||
def get_table_info():
|
def get_table_info():
|
||||||
"""API: get tables config."""
|
"""API: get tables config."""
|
||||||
|
from mes_dashboard.config.tables import TABLES_CONFIG
|
||||||
|
|
||||||
return jsonify(TABLES_CONFIG)
|
return jsonify(TABLES_CONFIG)
|
||||||
|
|
||||||
# ========================================================
|
# ========================================================
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ resource/equipment cache implementations.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
RESOURCE_TABLE = "DWH.DW_MES_RESOURCE"
|
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 = (
|
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"
|
EQUIPMENT_STATUS_VIEW = "DWH.DW_MES_EQUIPMENTSTATUS_WIP_V"
|
||||||
|
|||||||
@@ -1,589 +0,0 @@
|
|||||||
{% extends "_base.html" %}
|
|
||||||
|
|
||||||
{% block title %}MES 數據表查詢工具{% endblock %}
|
|
||||||
|
|
||||||
{% block head_extra %}
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Microsoft JhengHei', Arial, sans-serif;
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 30px;
|
|
||||||
border-radius: 8px 8px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
font-size: 28px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header p {
|
|
||||||
opacity: 0.9;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
padding: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-category {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
border-bottom: 2px solid #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-card {
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 15px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-card:hover {
|
|
||||||
border-color: #667eea;
|
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-card.active {
|
|
||||||
border-color: #667eea;
|
|
||||||
background: #f0f4ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-name {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #667eea;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-info {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
margin-top: 8px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-viewer {
|
|
||||||
margin-top: 30px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-viewer.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-header {
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
padding: 15px 20px;
|
|
||||||
border-radius: 6px 6px 0 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-header h3 {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
background: rgba(255,255,255,0.2);
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn:hover {
|
|
||||||
background: rgba(255,255,255,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container {
|
|
||||||
overflow-x: auto;
|
|
||||||
max-height: 600px;
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
border-top: none;
|
|
||||||
border-radius: 0 0 6px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
thead {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: #f8f9fa;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
padding: 12px 10px;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 2px solid #dee2e6;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #495057;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 10px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:hover {
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
color: #667eea;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
background: #fee;
|
|
||||||
color: #c33;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 11px;
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.large {
|
|
||||||
background: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
padding: 15px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 6px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-row input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-row input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #667eea;
|
|
||||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-row input::placeholder {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-row th {
|
|
||||||
padding: 8px 10px;
|
|
||||||
background: #e9ecef;
|
|
||||||
border-bottom: 1px solid #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.query-btn {
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 24px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.query-btn:hover {
|
|
||||||
background: #5a6fd6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.query-btn:disabled {
|
|
||||||
background: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-btn {
|
|
||||||
background: #6c757d;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-btn:hover {
|
|
||||||
background: #5a6268;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
margin-left: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-filters {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-tag {
|
|
||||||
background: #e3e8ff;
|
|
||||||
color: #667eea;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-tag .remove {
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-tag .remove:hover {
|
|
||||||
color: #dc3545;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>MES 數據表查詢工具</h1>
|
|
||||||
<p>點擊表名載入欄位 | 輸入篩選條件後查詢 | 套用篩選後取最後 1000 筆</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
{% for category, tables in tables_config.items() %}
|
|
||||||
<div class="table-category">
|
|
||||||
<div class="category-title">{{ category }}</div>
|
|
||||||
<div class="table-grid">
|
|
||||||
{% for table in tables %}
|
|
||||||
<div class="table-card" onclick="loadTableData('{{ table.name }}', '{{ table.display_name }}', '{{ table.time_field or '' }}')">
|
|
||||||
<div class="table-name">
|
|
||||||
{{ table.display_name }}
|
|
||||||
{% if table.row_count > 10000000 %}
|
|
||||||
<span class="badge large">大表</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="table-info">數據量: {{ "{:,}".format(table.row_count) }} 行</div>
|
|
||||||
{% if table.time_field %}
|
|
||||||
<div class="table-info">時間欄位: {{ table.time_field }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="table-desc">{{ table.description }}</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<div id="dataViewer" class="data-viewer">
|
|
||||||
<div class="viewer-header">
|
|
||||||
<h3 id="viewerTitle">數據查看器</h3>
|
|
||||||
<button class="close-btn" onclick="closeViewer()">關閉</button>
|
|
||||||
</div>
|
|
||||||
<div id="statsContainer"></div>
|
|
||||||
<div class="table-container">
|
|
||||||
<div id="tableContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
{% set tables_js = frontend_asset('tables.js') %}
|
|
||||||
{% if tables_js %}
|
|
||||||
<script type="module" src="{{ tables_js }}"></script>
|
|
||||||
{% else %}
|
|
||||||
<script>
|
|
||||||
let currentTable = null;
|
|
||||||
let currentDisplayName = null;
|
|
||||||
let currentTimeField = null;
|
|
||||||
let currentColumns = [];
|
|
||||||
let currentFilters = {};
|
|
||||||
|
|
||||||
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 = '<div class="loading">正在載入欄位資訊...</div>';
|
|
||||||
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 = `<div class="error">${data.error}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentColumns = data.columns;
|
|
||||||
title.textContent = `${displayName} (${currentColumns.length} 欄位)`;
|
|
||||||
|
|
||||||
renderFilterControls();
|
|
||||||
} catch (error) {
|
|
||||||
content.innerHTML = `<div class="error">請求失敗: ${error.message}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFilterControls() {
|
|
||||||
const statsContainer = document.getElementById('statsContainer');
|
|
||||||
const content = document.getElementById('tableContent');
|
|
||||||
|
|
||||||
statsContainer.innerHTML = `
|
|
||||||
<div class="stats">
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-label">表名</div>
|
|
||||||
<div class="stat-value" style="font-size: 14px;">${currentTable}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-label">欄位數</div>
|
|
||||||
<div class="stat-value">${currentColumns.length}</div>
|
|
||||||
</div>
|
|
||||||
<span class="filter-hint">在下方輸入框填入篩選條件 (模糊匹配)</span>
|
|
||||||
<button class="query-btn" onclick="executeQuery()">查詢</button>
|
|
||||||
<button class="clear-btn" onclick="clearFilters()">清除篩選</button>
|
|
||||||
</div>
|
|
||||||
<div id="activeFilters" class="active-filters"></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
let html = '<table><thead>';
|
|
||||||
html += '<tr>';
|
|
||||||
currentColumns.forEach(col => {
|
|
||||||
html += `<th>${col}</th>`;
|
|
||||||
});
|
|
||||||
html += '</tr>';
|
|
||||||
|
|
||||||
html += '<tr class="filter-row">';
|
|
||||||
currentColumns.forEach(col => {
|
|
||||||
html += `<th><input type="text" id="filter_${col}" placeholder="篩選..." onkeypress="handleFilterKeypress(event)" onchange="updateFilter('${col}', this.value)"></th>`;
|
|
||||||
});
|
|
||||||
html += '</tr>';
|
|
||||||
|
|
||||||
html += '</thead><tbody id="dataBody">';
|
|
||||||
html += '<tr><td colspan="' + currentColumns.length + '" style="text-align: center; padding: 40px; color: #666;">請輸入篩選條件後點擊「查詢」,或直接點擊「查詢」載入最後 1000 筆資料</td></tr>';
|
|
||||||
html += '</tbody></table>';
|
|
||||||
|
|
||||||
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 += `<span class="filter-tag">${col}: ${currentFilters[col]} <span class="remove" onclick="removeFilter('${col}')">×</span></span>`;
|
|
||||||
});
|
|
||||||
container.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFilter(column) {
|
|
||||||
delete currentFilters[column];
|
|
||||||
const input = document.getElementById(`filter_${column}`);
|
|
||||||
if (input) input.value = '';
|
|
||||||
renderActiveFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearFilters() {
|
|
||||||
currentFilters = {};
|
|
||||||
currentColumns.forEach(col => {
|
|
||||||
const input = document.getElementById(`filter_${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(`filter_${col}`);
|
|
||||||
if (input && input.value.trim()) {
|
|
||||||
currentFilters[col] = input.value.trim();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
renderActiveFilters();
|
|
||||||
|
|
||||||
title.textContent = `正在查詢: ${currentDisplayName}`;
|
|
||||||
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" class="loading">正在查詢資料...</td></tr>`;
|
|
||||||
|
|
||||||
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 = `<tr><td colspan="${currentColumns.length}" class="error">${data.error}</td></tr>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterCount = Object.keys(currentFilters).length;
|
|
||||||
const filterText = filterCount > 0 ? ` [${filterCount} 個篩選]` : '';
|
|
||||||
title.textContent = `${currentDisplayName} (${data.row_count} 筆)${filterText}`;
|
|
||||||
|
|
||||||
if (data.data.length === 0) {
|
|
||||||
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" style="text-align: center; padding: 40px; color: #999;">查無資料</td></tr>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
data.data.forEach(row => {
|
|
||||||
html += '<tr>';
|
|
||||||
currentColumns.forEach(col => {
|
|
||||||
const value = row[col];
|
|
||||||
const displayValue = value === null ? '<i style="color: #999;">NULL</i>' : value;
|
|
||||||
html += `<td>${displayValue}</td>`;
|
|
||||||
});
|
|
||||||
html += '</tr>';
|
|
||||||
});
|
|
||||||
tbody.innerHTML = html;
|
|
||||||
} catch (error) {
|
|
||||||
tbody.innerHTML = `<tr><td colspan="${currentColumns.length}" class="error">請求失敗: ${error.message}</td></tr>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeViewer() {
|
|
||||||
document.getElementById('dataViewer').classList.remove('active');
|
|
||||||
document.querySelectorAll('.table-card').forEach(card => {
|
|
||||||
card.classList.remove('active');
|
|
||||||
});
|
|
||||||
currentTable = null;
|
|
||||||
currentColumns = [];
|
|
||||||
currentFilters = {};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -507,6 +507,24 @@ class TestBuildFilterBuilder:
|
|||||||
sql = mock_read.call_args[0][0]
|
sql = mock_read.call_args[0][0]
|
||||||
assert RESOURCE_TABLE in sql
|
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:
|
class TestResourceDerivedIndex:
|
||||||
"""Test derived resource index and telemetry behavior."""
|
"""Test derived resource index and telemetry behavior."""
|
||||||
|
|||||||
@@ -54,14 +54,14 @@ class TestTemplateIntegration(unittest.TestCase):
|
|||||||
self.assertIn('mes-api.js', html)
|
self.assertIn('mes-api.js', html)
|
||||||
self.assertIn('mes-toast-container', 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')
|
response = self.client.get('/tables')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
html = response.data.decode('utf-8')
|
html = response.data.decode('utf-8')
|
||||||
|
|
||||||
self.assertIn('toast.js', html)
|
self.assertIn('/static/dist/tables.js', html)
|
||||||
self.assertIn('mes-api.js', html)
|
self.assertIn('type="module"', html)
|
||||||
self.assertIn('mes-toast-container', html)
|
self.assertNotIn('mes-toast-container', html)
|
||||||
|
|
||||||
def test_resource_page_includes_base_scripts(self):
|
def test_resource_page_includes_base_scripts(self):
|
||||||
response = self.client.get('/resource')
|
response = self.client.get('/resource')
|
||||||
|
|||||||
Reference in New Issue
Block a user