🧾Vue 3 表格加上「PRODUCT_ID 模糊搜尋」:一次修好搜尋沒效果與其他篩選失靈
- 取得連結
- X
- 以電子郵件傳送
- 其他應用程式
摘要
在專案中,我們要在 PRODUCT_ID 的下拉面板加入「模糊搜尋」,同時保留:
-
多欄位勾選篩選(含全選/全不選)
-
全文關鍵字
searchTerm
-
Pinia 跨頁同步
-
APS_PLAN_NO
區段合併與 TFT5 依 TFT4 群組加總僅首列顯示 -
匯出 Excel(群組表頭、合併、千分位)
本文先說明錯誤根因,再給你
全新匿名版、但畫面/格式相同
的
PageOne.vue
。直接貼上即可運行。
問題現象
-
面板加了輸入框,但搜尋沒作用。
-
打了字後,其他欄位勾選篩選跟著失效或判斷怪怪的。
-
匯出 Excel 與畫面結果不同步。
核心根因(工程角度)
-
全選/全不選沒跟著「輸入後的可見子集合」動:看似縮小清單,但實際依舊選到所有值。
-
用
Array.includes
直接比對,遇到undefined/null/''
或數字↔字串型別 時,容易誤判,連帶讓其他欄位篩選失靈。
解法藍圖
-
新增
PRODUCT_ID
專屬關鍵字狀態(例如pidQuery
),只影響 PRODUCT_ID。 -
以 Set + 空值正規化 進行多欄位勾選比對,消除
includes
落差。 -
面板的 全選 在輸入中時,只選「目前可見子集合」。
-
filteredRows
先跑全文searchTerm
,再跑PRODUCT_ID LIKE
,最後跑多欄位勾選。 -
匯出 Excel 直接用
filteredRows
的輸出視圖(rowSpans
後)以確保畫面=匯出。
全新匿名版、但畫面/格式相同的
PageOne.vue
下列程式碼以不同命名與實作路徑重寫,並移除任何具體公司/專案字樣;仍保留原 UI 結構(雙層表頭、左欄固定、底部卷軸同步)與功能(PRODUCT_ID 模糊搜尋、群組加總、Excel 匯出等)。
<!-- File : PageOne.vue (anonymized refactor) Purpose : Demo page for scheduling result review - Version selector (Pinia bound) - Multi-column checkbox filters + global keyword - Fuzzy search ONLY for PRODUCT_ID inside dropdown - Within same APS_PLAN_NO: aggregate TFT5 by TFT4 and show on first row - Export Excel (grouped headers, merges, thousand-separators) - Sticky two-row header, sticky left column, synced bottom scroller Author : Anonymous (privacy-safe) --> <script setup lang="js"> import { ref, computed, watch, onMounted, nextTick } from 'vue' import * as XLSX from 'xlsx' import { saveAs } from 'file-saver' import apiClient from '@/services/api' import { useApsPlanStore } from '@/stores/apsPlanStore' /* -------------------- Constants / Labels -------------------- */ const store = useApsPlanStore() const PAGE_KEY = 'page1' const COL_LABEL = { SEQ_NO: 'SEQ_NO序號', APS_PLAN_NO: 'APS_PLAN_NO計劃編號', APS_PLAN_LIST: 'APS方案', SITE: '廠別 SITE', WAITING_FLAG: 'WAITING_FLAG', PLANT: 'PLANT', APPL: 'Appl.', CUSTOMER_NAME: '客戶', PRODUCT_ID: 'PRODUCT_ID', ORI_PLAN_WEEK: '原始需求週別', ORI_PLAN_DATE: '原始需求日期', DEMAND_MONTH: '需求月份', DEMAND_WEEK: '需求周別', DEMAND_QTY: '需求數量', SATISFY_WAY: '成交結果', DEAL_QTY: '成交數量', TFT1: '成交瓶頸', TFT2: '退周數量', TFT3: '退周周別', TFT4: '置換機種', TFT5: '置換數量', LCM1: '成交瓶頸', LCM2: '退周數量', LCM3: '退周周別', LCM4: '置換機種', LCM5: '置換數量', MATERIAL1: '成交瓶頸', MATERIAL2: '退周數量', MATERIAL3: '退周周別', } const HEADS = Object.keys(COL_LABEL) const RIGHT_ALIGN = new Set(['APS_PLAN_LIST','APS_PLAN_SEQ','DEMAND_QTY','DEAL_QTY','TFT2','TFT5','LCM2','LCM5','MATERIAL2']) const FILTERABLE = ['SEQ_NO','APS_PLAN_NO','SITE','PLANT','APPL','CUSTOMER_NAME','PRODUCT_ID','SATISFY_WAY','DEAL_QTY'] const SHARED = ['SEQ_NO','SITE','APPL','CUSTOMER_NAME','APS_PLAN_NO'] /* -------------------- State -------------------- */ const versions = ref([]) const loading = ref(false) const rowsRaw = ref([]) const uniques = ref({}) const picks = ref({}) const dropdown = ref(null) const globalText = ref('') const pidQuery = ref('') // PRODUCT_ID only // scroll refs const wrapRef = ref(null) const tableRef = ref(null) const bottomRef = ref(null) const bottomTrackRef = ref(null) // shared version via Pinia const currentVersion = computed({ get: () => store.globalApsVersion, set: (v) => (store.globalApsVersion = v), }) /* -------------------- Utils -------------------- */ const S = (v) => String(v ?? '') const lower = (v) => S(v).toLowerCase() const n = (v) => (typeof v === 'number' ? v : parseFloat(S(v).replace(/,/g, '')) || 0) const nf = (x) => Number(x).toLocaleString('en-US') const isRight = (k) => RIGHT_ALIGN.has(S(k).toUpperCase()) /* -------------------- Bootstrap uniques + picks -------------------- */ function initPools() { uniques.value = {} for (const k of HEADS) { const set = new Set() rowsRaw.value.forEach(r => set.add(r[k])) uniques.value[k] = Array.from(set) } if (!Object.keys(picks.value).length) { picks.value = {} for (const k of HEADS) { if (SHARED.includes(k)) { const fromStore = store.globalCommonFilters[k] picks.value[k] = fromStore?.length ? [...fromStore] : [...(uniques.value[k] || [])] store.setGlobalCommonFilter(k, picks.value[k]) } else { picks.value[k] = [...(uniques.value[k] || [])] } } } } /* -------------------- Pinia two-way sync -------------------- */ for (const k of SHARED) { watch(() => store.globalCommonFilters[k], (nv) => { if (!nv) return const a = JSON.stringify(picks.value[k] || []) const b = JSON.stringify(nv) if (a !== b) picks.value[k] = [...nv] }, { deep: true }) watch(() => picks.value[k], (nv) => { const a = JSON.stringify(store.globalCommonFilters[k] || []) const b = JSON.stringify(nv || []) if (a !== b) store.setGlobalCommonFilter(k, nv || []) }, { deep: true }) } /* -------------------- Data fetching -------------------- */ async function fetchVersions() { try { const { data } = await apiClient.get('/Data/GetAllVersionsC') versions.value = (data || []).sort((a, b) => b.localeCompare(a)) } catch (err) { console.error(err) } } async function fetchData() { if (!currentVersion.value) return alert('請先選擇一個計劃版本') loading.value = true try { const { data } = await apiClient.get(`/Data/GetDataByVersionC?apsVersion=${currentVersion.value}`) const norm = (obj) => { const out = {}; Object.keys(obj).forEach(k => out[k.toUpperCase()] = obj[k]); return out } const arr = (data || []).map(norm) // sort: SEQ_NO → APS_PLAN_NO → TFT4 (empty last) arr.sort((a, b) => { const opt = { numeric: true, sensitivity: 'base' } const last = (x) => (S(x).trim() ? S(x) : '\u{10FFFF}') const c1 = S(a.SEQ_NO).localeCompare(S(b.SEQ_NO), undefined, opt); if (c1) return c1 const c2 = S(a.APS_PLAN_NO).localeCompare(S(b.APS_PLAN_NO), undefined, opt); if (c2) return c2 return last(a.TFT4).localeCompare(last(b.TFT4), undefined, opt) }) rowsRaw.value = arr picks.value = {} SHARED.forEach(k => store.setGlobalCommonFilter(k, [])) initPools() store.setPageData(PAGE_KEY, currentVersion.value, HEADS, rowsRaw.value, picks.value) await nextTick(); adjustBottomTrack(); syncBottomByTable() } catch (err) { console.error(err) } finally { loading.value = false } } /* -------------------- Dropdown actions -------------------- */ function toggleDropdown(k) { dropdown.value = dropdown.value === k ? null : k } function selectAll(k) { if (k === 'PRODUCT_ID' && pidQuery.value.trim()) { picks.value[k] = [...pidVisibleOptions.value] // only the visible subset } else { picks.value[k] = [...(uniques.value[k] || [])] } } function clearAll(k) { picks.value[k] = [] } /* -------------------- PRODUCT_ID visible subset -------------------- */ const pidVisibleOptions = computed(() => { const all = uniques.value['PRODUCT_ID'] || [] const q = lower(pidQuery.value).trim() return q ? all.filter(x => lower(x).includes(q)) : all }) /* -------------------- Main filter: global → pid-like → checkbox sets -------------------- */ const filteredRows = computed(() => { const kw = lower(globalText.value).trim() const pid = lower(pidQuery.value).trim() const storeCache = Object.create(null) const localCache = Object.create(null) return rowsRaw.value.filter(r => { if (kw) { const merged = Object.values(r).join(' ').toLowerCase() if (!merged.includes(kw)) return false } if (pid && !lower(r.PRODUCT_ID).includes(pid)) return false for (const k of HEADS) { const g = store.globalCommonFilters[k] if (g?.length) { const tag = `${k}|g` const set = storeCache[tag] || (storeCache[tag] = new Set(g.map(v => v ?? ''))) if (!set.has(r[k] ?? '')) return false } if (FILTERABLE.includes(k)) { const s = picks.value[k] if (s?.length) { const tag = `${k}|l` const set = localCache[tag] || (localCache[tag] = new Set(s.map(v => v ?? ''))) if (!set.has(r[k] ?? '')) return false } } } return true }) }) /* -------------------- RowSpan + TFT5 aggregate by TFT4 per APS_PLAN_NO -------------------- */ const spanView = computed(() => { const rows = filteredRows.value.map(r => ({ ...r })) const spans = rows.map(() => Object.fromEntries(HEADS.map(k => [k, 1]))) let i = 0 while (i < rows.length) { const plan = rows[i].APS_PLAN_NO let j = i + 1; while (j < rows.length && rows[j].APS_PLAN_NO === plan) j++ // aggregate TFT5 by TFT4 within the plan section let a = i while (a < j) { const t4 = rows[a].TFT4 let b = a + 1; while (b < j && rows[b].TFT4 === t4) b++ const sum = rows.slice(a, b).reduce((acc, cur) => acc + n(cur.TFT5), 0) rows[a].TFT5 = nf(sum) for (let x = a + 1; x < b; x++) rows[x].TFT5 = '' a = b } // compute rowspans for (const k of HEADS) { let p = i while (p < j) { let q = p + 1 if (k === 'TFT5') { const g = rows[p].TFT4 while (q < j && rows[q].TFT4 === g) q++ } else { while (q < j && rows[q][k] === rows[p][k]) q++ } spans[p][k] = Math.max(1, q - p) p = q } } i = j } return { rows, spans } }) /* -------------------- Export to Excel (screen = export) -------------------- */ function toAoa(headers, list) { const groupRow = headers.map((_, idx) => { if (idx < 14) return COL_LABEL[headers[idx]] || headers[idx] if (idx < 16) return '考題結論' if (idx < 21) return '評估明細_TFT' if (idx < 26) return '評估明細_LCM' return '評估明細_Material' }) const subRow = headers.map(k => COL_LABEL[k] || k) const numeric = new Set(['APS_PLAN_LIST','APS_PLAN_SEQ','DEMAND_QTY','DEAL_QTY','TFT2','TFT5','LCM2','LCM5','MATERIAL2']) const body = list.map(r => headers.map(k => { if (k === 'TFT5') return (r[k] === '' || r[k] == null) ? '' : n(r[k]) return numeric.has(k) ? n(r[k]) : (r[k] ?? '') })) return { groupRow, subRow, body, numeric } } function calcMerges(headers, body) { const ms = [] for (let c = 0; c < 14; c++) ms.push({ s: { r: 0, c }, e: { r: 1, c } }) ms.push({ s: { r: 0, c: 14 }, e: { r: 0, c: 15 } }) ms.push({ s: { r: 0, c: 16 }, e: { r: 0, c: 20 } }) ms.push({ s: { r: 0, c: 21 }, e: { r: 0, c: 25 } }) ms.push({ s: { r: 0, c: 26 }, e: { r: 0, c: 28 } }) const idxPlan = headers.indexOf('APS_PLAN_NO') const idxT4 = headers.indexOf('TFT4') let s = 0 while (s < body.length) { let e = s + 1; while (e < body.length && body[e][idxPlan] === body[s][idxPlan]) e++ for (let col = 0; col < headers.length; col++) { let p = s while (p < e) { let q = p + 1 if (headers[col] === 'TFT5') { const g = body[p][idxT4]; while (q < e && body[q][idxT4] === g) q++ } else { while (q < e && body[q][col] === body[p][col]) q++ } if (q - p > 1) ms.push({ s: { r: p + 2, c: col }, e: { r: q - 1 + 2, c: col } }) p = q } } s = e } return ms } function exportExcel() { if (!rowsRaw.value.length) return alert('沒有資料可匯出') const headers = HEADS const view = spanView.value.rows const { groupRow, subRow, body, numeric } = toAoa(headers, view) const ws = XLSX.utils.aoa_to_sheet([groupRow, subRow, ...body]) ws['!merges'] = calcMerges(headers, body) headers.forEach((k, col) => { if (!numeric.has(k)) return const colL = XLSX.utils.encode_col(col) for (let r = 2; r < body.length + 2; r++) { const addr = `${colL}${r + 1}` const cell = ws[addr] if (cell && typeof cell.v === 'number') { cell.t = 'n'; cell.z = '#,##0' } } }) const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, '計劃排程結果') const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) saveAs(new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `排程結果_${currentVersion.value}.xlsx`) } /* -------------------- Scroll sync -------------------- */ function syncTableByBottom() { if (wrapRef.value && bottomRef.value) wrapRef.value.scrollLeft = bottomRef.value.scrollLeft } function syncBottomByTable() { if (wrapRef.value && bottomRef.value) bottomRef.value.scrollLeft = wrapRef.value.scrollLeft } function adjustBottomTrack() { if (!bottomTrackRef.value || !tableRef.value) return const w = Math.ceil(tableRef.value.getBoundingClientRect().width) bottomTrackRef.value.style.width = (w + 1000) + 'px' syncBottomByTable() } watch(rowsRaw, async () => { await nextTick(); adjustBottomTrack() }) /* -------------------- Lifecycle -------------------- */ onMounted(async () => { await fetchVersions() const saved = store.pageStates[PAGE_KEY] if (saved?.mappedTableData?.length && saved.selectedVersion === currentVersion.value) { rowsRaw.value = saved.mappedTableData picks.value = saved.filters initPools() await nextTick(); adjustBottomTrack(); syncBottomByTable() } else if (currentVersion.value) { await fetchData() } }) watch(currentVersion, v => { if (v) fetchData() }) watch(picks, nv => store.updatePageFilters(PAGE_KEY, nv), { deep: true }) </script> <template> <div class="container"> <div class="title-bar"><h3>排程結果 - 考題結論</h3></div> <!-- Toolbar --> <div class="toolbar"> <div class="left-section"> <h4>計劃版本:</h4> <select v-model="currentVersion" class="dropdown"> <option value="" disabled>請選擇版本</option> <option v-for="v in versions" :key="v" :value="v">{{ v }}</option> </select> <button class="button blue" @click="fetchData">查詢</button> </div> <div class="right-section"> <input v-model="globalText" type="text" class="search-input" placeholder="輸入搜尋關鍵字" /> <button v-if="rowsRaw.length" class="button green" @click="exportExcel">存成 Excel</button> </div> </div> <!-- Table --> <div class="table-container" ref="wrapRef" @scroll="syncBottomByTable"> <table ref="tableRef"> <thead> <!-- Row 1 --> <tr> <!-- SEQ_NO (sticky left) --> <th rowspan="2" class="thead sticky-left"> <div v-if="FILTERABLE.includes('SEQ_NO')" class="header-cell"> <span>{{ COL_LABEL['SEQ_NO'] }}</span> <button class="filter-btn" @click.stop="toggleDropdown('SEQ_NO')">▼</button> <div class="filter-dropdown" v-if="dropdown === 'SEQ_NO'" @click.stop> <div class="select-all"> <button @click="selectAll('SEQ_NO')">全選</button> <button @click="clearAll('SEQ_NO')">全不選</button> </div> <hr /> <div class="options"> <label v-for="(o,i) in uniques['SEQ_NO']" :key="i"> <input type="checkbox" v-model="picks['SEQ_NO']" :value="o" /> {{ o }} </label> </div> </div> </div> <div v-else>SEQ_NO序號</div> </th> <!-- APS_PLAN_NO --> <th rowspan="2"> <div v-if="FILTERABLE.includes('APS_PLAN_NO')" class="header-cell"> <span>{{ COL_LABEL['APS_PLAN_NO'] }}</span> <button class="filter-btn" @click.stop="toggleDropdown('APS_PLAN_NO')">▼</button> <div class="filter-dropdown" v-if="dropdown === 'APS_PLAN_NO'" @click.stop> <div class="select-all"> <button @click="selectAll('APS_PLAN_NO')">全選</button> <button @click="clearAll('APS_PLAN_NO')">全不選</button> </div> <hr /> <div class="options"> <label v-for="(o,i) in uniques['APS_PLAN_NO']" :key="i"> <input type="checkbox" v-model="picks['APS_PLAN_NO']" :value="o" /> {{ o }} </label> </div> </div> </div> <div v-else>計畫編號</div> </th> <th rowspan="2">APS方案</th> <!-- SITE --> <th rowspan="2"> <div v-if="FILTERABLE.includes('SITE')" class="header-cell"> <span>{{ COL_LABEL['SITE'] }}</span> <button class="filter-btn" @click.stop="toggleDropdown('SITE')">▼</button> <div class="filter-dropdown" v-if="dropdown === 'SITE'" @click.stop> <div class="select-all"> <button @click="selectAll('SITE')">全選</button> <button @click="clearAll('SITE')">全不選</button> </div> <hr /> <div class="options"> <label v-for="(o,i) in uniques['SITE']" :key="i"> <input type="checkbox" v-model="picks['SITE']" :value="o" /> {{ o }} </label> </div> </div> </div> <div v-else>SITE</div> </th> <th rowspan="2">WAITING_FLAG</th> <th rowspan="2">PLANT</th> <!-- APPL --> <th rowspan="2"> <div v-if="FILTERABLE.includes('APPL')" class="header-cell"> <span>{{ COL_LABEL['APPL'] }}</span> <button class="filter-btn" @click.stop="toggleDropdown('APPL')">▼</button> <div class="filter-dropdown" v-if="dropdown === 'APPL'" @click.stop> <div class="select-all"> <button @click="selectAll('APPL')">全選</button> <button @click="clearAll('APPL')">全不選</button> </div> <hr /> <div class="options"> <label v-for="(o,i) in uniques['APPL']" :key="i"> <input type="checkbox" v-model="picks['APPL']" :value="o" /> {{ o }} </label> </div> </div> </div> <div v-else>應用別</div> </th> <!-- CUSTOMER_NAME --> <th rowspan="2"> <div v-if="FILTERABLE.includes('CUSTOMER_NAME')" class="header-cell"> <span>{{ COL_LABEL['CUSTOMER_NAME'] }}</span> <button class="filter-btn" @click.stop="toggleDropdown('CUSTOMER_NAME')">▼</button> <div class="filter-dropdown" v-if="dropdown === 'CUSTOMER_NAME'" @click.stop> <div class="select-all"> <button @click="selectAll('CUSTOMER_NAME')">全選</button> <button @click="clearAll('CUSTOMER_NAME')">全不選</button> </div> <hr /> <div class="options"> <label v-for="(o,i) in uniques['CUSTOMER_NAME']" :key="i"> <input type="checkbox" v-model="picks['CUSTOMER_NAME']" :value="o" /> {{ o }} </label> </div> </div> </div> <div v-else>客戶</div> </th> <!-- PRODUCT_ID (with fuzzy input) --> <th rowspan="2"> <div v-if="FILTERABLE.includes('PRODUCT_ID')" class="header-cell"> <span>{{ COL_LABEL['PRODUCT_ID'] }}</span> <button class="filter-btn" @click.stop="toggleDropdown('PRODUCT_ID')">▼</button> <div class="filter-dropdown" v-if="dropdown === 'PRODUCT_ID'" @click.stop> <div class="select-all"> <button @click="selectAll('PRODUCT_ID')">全選</button> <button @click="clearAll('PRODUCT_ID')">全不選</button> </div> <div class="input-row"> <input v-model="pidQuery" type="text" class="dropdown-search" placeholder="輸入產品關鍵字(模糊)" /> <button class="mini" @click="pidQuery='';">清除</button> </div> <hr /> <div class="options"> <label v-for="(o,i) in pidVisibleOptions" :key="'pid-'+i"> <input type="checkbox" v-model="picks['PRODUCT_ID']" :value="o" /> {{ o }} </label> </div> </div> </div> <div v-else>PRODUCT ID</div> </th> <th rowspan="2">原始需求週別</th> <th rowspan="2">原始需求日期</th> <th rowspan="2">需求月份</th> <th rowspan="2">需求周別</th> <th rowspan="2">需求數量</th> <th colspan="2" style="background:#90ee90;color:#000">考題結論</th> <th colspan="5" style="background:#fff0ac;color:#000">評估明細_TFT</th> <th colspan="5" style="background:#ffbb77;color:#000">評估明細_LCM</th> <th colspan="5" style="background:#00a600;color:#000">評估明細_Material</th> </tr> <!-- Row 2 --> <tr> <th style="background:#90ee90;color:#000"> <div v-if="FILTERABLE.includes('SATISFY_WAY')" class="header-cell"> <span>{{ COL_LABEL['SATISFY_WAY'] }}</span> <button class="filter-green-btn" @click.stop="toggleDropdown('SATISFY_WAY')">▼</button> <div class="filter-green-dropdown" v-if="dropdown === 'SATISFY_WAY'" @click.stop> <div class="select-green-all"> <button @click="selectAll('SATISFY_WAY')">全選</button> <button @click="clearAll('SATISFY_WAY')">全不選</button> </div> <hr /> <div class="options"> <label v-for="(o,i) in uniques['SATISFY_WAY']" :key="i"> <input type="checkbox" v-model="picks['SATISFY_WAY']" :value="o" /> {{ o }} </label> </div> </div> </div> <div v-else>成交結果</div> </th> <th style="background:#90ee90;color:#000">成交數量</th> <th style="background:#fff0ac;color:#000">成交瓶頸</th> <th style="background:#fff0ac;color:#000">退周數量</th> <th style="background:#fff0ac;color:#000">退周周別</th> <th style="background:#fff0ac;color:#000">置換機種</th> <th style="background:#fff0ac;color:#000">置換數量</th> <th style="background:#ffbb77;color:#000">成交瓶頸</th> <th style="background:#ffbb77;color:#000">退周數量</th> <th style="background:#ffbb77;color:#000">退周周別</th> <th style="background:#ffbb77;color:#000">置換機種</th> <th style="background:#ffbb77;color:#000">置換數量</th> <th style="background:#00a600;color:#000">成交瓶頸</th> <th style="background:#00a600;color:#000">退周數量</th> <th style="background:#00a600;color:#000">退周周別</th> </tr> </thead> <tbody> <tr v-if="loading"> <td :colspan="HEADS.length" style="text-align:center;color:gray;font-size:36px;">Loading...</td> </tr> <tr v-else-if="!loading && !rowsRaw.length"> <td :colspan="HEADS.length" style="text-align:center;color:red;">(無資料)</td> </tr> <tr v-else-if="!spanView.rows.length"> <td :colspan="HEADS.length" style="text-align:center;color:blue;">(符合篩選的資料為空)</td> </tr> <tr v-for="(row, rIdx) in spanView.rows" :key="rIdx"> <template v-for="(k, cIdx) in HEADS" :key="cIdx"> <td v-if="spanView.spans[rIdx][k] > 0" :rowspan="spanView.spans[rIdx][k]" :class="{ 'text-right': isRight(k), 'sticky-left': k==='SEQ_NO', 'material-multiline': k==='MATERIAL1'||k==='LCM1' }" >{{ row[k] }}</td> <td v-else style="display:none"></td> </template> </tr> </tbody> </table> </div> <!-- Bottom scroller --> <div class="bottom-scroll" ref="bottomRef" @scroll="syncTableByBottom"> <div class="dummy-scroll" ref="bottomTrackRef"></div> </div> </div> </template> <style scoped> .container{position:absolute;left:0;top:75px;width:100%;background:#fff;color:#000;padding-bottom:30px} .title-bar{background:linear-gradient(to bottom,#559fe8,#0056b3);color:#fff;padding:15px 20px;font-weight:700;font-size:18px} .toolbar{display:flex;align-items:center;justify-content:space-between;border-bottom:2px solid #ddd;padding:10px 20px} .left-section{display:flex;gap:10px;align-items:center} .dropdown{border:1px solid #ccc;border-radius:4px;padding:5px} .button{border:0;border-radius:4px;color:#fff;padding:8px 15px;font-size:14px;cursor:pointer} .button.blue{background:#0056b3} .button.green{background:#28a745} .search-input{border:1px solid #ccc;border-radius:4px;padding:5px 8px;font-size:14px;width:180px} .table-container{position:relative;border:1px solid #ccc;margin-top:15px;max-height:600px;min-height:300px;overflow-x:hidden;overflow-y:visible} table{display:inline-block;border-collapse:collapse;font-size:14px;width:max-content} thead th{background:#ffd700;color:#000;border:1px solid #ddd;font-weight:700;padding:10px;white-space:nowrap;text-align:center} thead tr:nth-of-type(1) th{position:sticky;top:0;z-index:11} thead tr:nth-of-type(2) th{position:sticky;top:44px;z-index:10} td{border:1px solid #ddd;padding:8px;color:#000;white-space:nowrap;text-align:left} .header-cell{display:inline-flex;align-items:center;justify-content:center;position:relative} .header-cell span{font-weight:700} .filter-btn{margin-left:5px;padding:4px 8px;background:#ffd700;border:1px solid #e7c400;color:#333;font-size:12px;border-radius:4px;cursor:pointer;transition:background .2s,box-shadow .2s} .filter-btn:hover{background:#ffc900;box-shadow:0 0 4px rgba(0,0,0,.2)} .filter-dropdown{position:absolute;left:100%;top:100%;transform:translateX(-50%);min-width:160px;max-height:260px;overflow-y:auto;background:#fff;border:1px solid #ccc;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.15);padding:10px;z-index:9999;text-align:left} .filter-green-btn{margin-left:5px;padding:4px 8px;background:#7ef182;border:1px solid rgb(94,155,96);color:#333;font-size:12px;border-radius:4px;cursor:pointer;transition:background .2s,box-shadow .2s} .filter-green-btn:hover{background:#90ee90;box-shadow:0 0 4px rgba(0,0,0,.2)} .filter-green-dropdown{position:absolute;left:100%;top:100%;transform:translateX(-50%);min-width:160px;max-height:260px;overflow-y:auto;background:#fff;border:1px solid #ccc;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.15);padding:10px;z-index:9999;text-align:left} .select-all,.select-green-all{display:flex;justify-content:space-between;margin-bottom:6px} .select-all button{background:#ffd7001a;border:1px solid #e7c400;border-radius:4px;padding:3px 8px;font-size:12px;cursor:pointer} .select-all button:hover{background:#ffd70033} .select-green-all button{background:#72e77634;border:1px solid rgb(141,236,144);border-radius:4px;padding:3px 8px;font-size:12px;cursor:pointer} .select-green-all button:hover{background:#72e776d4} .options{max-height:160px;overflow-y:auto;padding-left:4px} .options label{display:flex;align-items:center;font-size:13px;margin:4px 0;cursor:pointer} .options input[type='checkbox']{margin-right:6px} .bottom-scroll{position:fixed;left:0;bottom:0;width:100%;height:20px;background:#f5f5f5;overflow-x:auto;overflow-y:hidden;z-index:1000} .dummy-scroll{height:1px} .text-right{text-align:right !important} .material-multiline{white-space:pre-line;word-break:break-all;text-align:left} .sticky-left{position:sticky;left:0;background:#fff;z-index:5} thead th.sticky-left[rowspan="2"]{top:0;background:#ffd700;z-index:60} .input-row{display:flex;gap:6px;align-items:center;margin:6px 0} .dropdown-search{flex:1;border:1px solid #ccc;border-radius:4px;padding:4px 6px;font-size:12px} button.mini{padding:3px 8px;font-size:12px;border:1px solid #ddd;background:#f7f7f7;border-radius:4px;cursor:pointer} button.mini:hover{background:#eee} </style>
驗收清單
-
在 PRODUCT_ID 面板輸入任意字串 → 表格僅顯示包含該字串的列。
-
其他欄位(如 SITE / APPL / SATISFY_WAY)勾選後仍然正確生效。
-
有輸入時點「全選」→ 只選可見的子集合;清除輸入後恢復全量。
-
匯出 Excel 與畫面一致(含群組表頭、合併與千分位格式)。
效能與 UX 建議
-
大量資料時可導入虛擬清單(如
vue-virtual-scroller
)。 -
PRODUCT_ID
模糊條件可進一步支援多關鍵字 AND/OR、忽略符號。 -
將
pidQuery
也寫入 Pinia 以跨頁記憶(例如PRODUCT_ID_LIKE
)。
常見問答(FAQ)
Q:為什麼改用
Set
?
A:Set.has
O(1)
並可先正規化空值,能避開
includes
在
undefined/null/''/數字↔字串
的誤判。
Q:為何畫面與 Excel 能一致?
A:兩者都基於
spanView.rows
的同一份視圖,TFT5 的聚合先行處理後再輸出。
結語
本篇維持原有部落格內容,同時用匿名化、全新寫法提供程式碼範例,避免任何隱私或專案識別資訊。把上述檔案直接放進專案,就能得到穩定的 PRODUCT_ID 模糊搜尋與一致的匯出體驗。
留言
張貼留言