🌐Vue 3 匯出 Excel 筆數變少、資料重複?— 工程師一步步帶你找出篩選與合併的地雷(含完整修正範例)

 

內容

TL;DR
匯出的 Excel 筆數變少/重複,通常不是你「少抓資料」,而是被篩選過不小心把行合併了。三大元凶:

  1. 匯出用 filteredData 而非完整的 mappedTableData

  2. Pinia 的全域篩選殘留,初始化順序錯亂;

  3. 表頭鍵名對不上(中英文映射)+合併 merges/rowSpans 把不同列「壓成一列」。
    先用無合併、無格式化的純匯出驗證筆數,確認 OK 再逐步加回樣式與合併。




一、問題現象(非技術也看得懂)

我們要把「NB急單計劃排程結果 - 考題結論」這張表匯出到 Excel。結果下載後發現:

  • 筆數變少:明明畫面上很多筆,Excel 檔卻只有一小部分。

  • 資料重複:同樣的計劃編號/序號反覆出現,好像被「擠在一起」。

  • 與參考檔案不一致:跟 inx0728(內部參考檔)比對,欄位名稱與筆數都對不起來。




二、背景架構(快速理解)

  • 前端:Vue 3 + Pinia 管理狀態,頁面有搜尋關鍵字與多欄位下拉篩選。

  • 匯出:用 SheetJS (XLSX) 產生 Excel,還做了表頭群組儲存格合併merges)。

  • 欄位:畫面顯示用中文標題(例如 SEQ_NO序號APS_PLAN_NO計劃編號),但原始資料是英文鍵名(例如 SEQ_NOAPS_PLAN_NO)。

關鍵:匯出的資料來源、表頭鍵名映射、合併邏輯,三者要完全對齊,否則就會「看起來像少筆或重複」。




三、三大根因(工程師視角拆解)

1) 匯出用 filteredData 導致筆數變少

匯出程式是這樣寫的:

const dataToExport = filteredData.value

filteredData 是「套用搜尋與篩選後的結果」。
只要有殘留過濾(包含你上一頁留下來的 Pinia 全域篩選),匯出就只剩一部分。

✅ 修正:若要完整匯出,不吃篩選,改成:

const dataToExport = mappedTableData.value



2) Pinia 全域篩選殘留/初始化順序錯誤

你會把每頁的常用欄位篩選存在 Pinia(globalCommonFilters)。若先清空先賦值的順序不對,initUniqueAndFilters() 會以為「篩選值是空」,結果所有列都被過濾掉

✅ 修正:

  • 先用實際資料的 unique 值建立初始篩選,再把它存回 Pinia

  • 或在切版本/重查前,明確清掉全域篩選,避免前一頁的條件外溢。




3) 表頭鍵名映射 + 合併邏輯 讓資料「被擠扁」

  • 你的 tableHeaders 來自 Object.keys(uppercaseMapping)(中文展示用),但原始資料鍵名來自後端(英文)。一旦對不上,某些欄位就變空,之後又用 rowSpans/merges 依值合併,不同列就被誤判一致 → 看起來像重複或少筆。

  • merges 若以區段/值合併但鍵名不對、或排序/分組鍵不完整,也會「壓扁資料」。

✅ 修正:

  • 後端回傳的實際鍵名動態生成 tableHeaders

  • 不要做合併,確認筆數與內容正確後再慢慢加回。

  • 合併前,務必確認分組鍵(如 APS_PLAN_NOSEQ_NO排序一致。




四、逐步排查(1 次只做 1 件事)

  1. 加日誌先看筆數

    console.log('mappedTableData:', mappedTableData.value.length)
    console.log('filteredData:', filteredData.value.length)
    

    如果 filteredData < mappedTableData,代表「被篩選了」。

  2. 停用篩選後匯出
    暫時改成匯出 mappedTableData,看看筆數是否回到正確。

  3. 停用合併與格式
    先用純匯出驗證:

    import * as XLSX from 'xlsx'
    const ws = XLSX.utils.json_to_sheet(mappedTableData.value)
    const wb = XLSX.utils.book_new()
    XLSX.utils.book_append_sheet(wb, ws, '考題結論')
    // save...
    

    如果此時筆數正確,表示「篩選/表頭/合併」其中某一步是兇手。

  4. 對齊欄位名稱與順序
    依參考檔(例如 inx0728)定義 header 順序,避免欄位丟失:

    const headerOrder = ['APS_VERSION','APS_PLAN_NO','DC_ORDER_NO','DOC_NO', /* ... */]
    const ws = XLSX.utils.json_to_sheet(mappedTableData.value, { header: headerOrder })
    
  5. 最後才加回合併
    每加一段 merges 就重匯一次,確認筆數沒被影響。




五、建議修正:關鍵片段(可直接套)

(A) 以「資料實際欄位」建立表頭

// 拿到 res.data 後
const rows = res.data.map(item => {
  const o = {}
  Object.keys(item).forEach(k => { o[k.toUpperCase()] = item[k] })
  return o
})

// 以資料實際鍵建立 header(避免硬卡在映射表)
tableHeaders.value = Object.keys(rows[0] || {}).map(k => k.toUpperCase())

(B) 初始化篩選:先建、後存

filters.value = {}
// 先跑 unique
tableHeaders.value.forEach(hdr => {
  const colVals = rows.map(r => r[hdr])
  uniqueValues.value[hdr] = Array.from(new Set(colVals))
})
// 再設 filters 初值
tableHeaders.value.forEach(hdr => {
  filters.value[hdr] = [...uniqueValues.value[hdr]]
})
// 最後存回 Pinia(避免空白覆蓋)
tableHeaders.value.forEach(hdr => {
  if (commonFilterKeys.includes(hdr)) {
    store.setGlobalCommonFilter(hdr, filters.value[hdr])
  }
})

(C) 匯出一開始用「完整資料」

function exportToExcel() {
  if (!mappedTableData.value.length) {
    alert('沒有資料可匯出')
    return
  }
  console.log('mapped:', mappedTableData.value.length)
  console.log('filtered:', filteredData.value.length)

  // 先用完整資料驗證筆數
  const dataToExport = mappedTableData.value

  const ws = XLSX.utils.json_to_sheet(dataToExport)
  const wb = XLSX.utils.book_new()
  XLSX.utils.book_append_sheet(wb, ws, '考題結論')
  // 後續再加格式與 merges
}

(D) 加回指定順序與中文表頭(可選)

const headerOrder = ['APS_VERSION','APS_PLAN_NO','SEQ_NO', /* ... */]
const humanReadableMap = {
  APS_VERSION: '版本',
  APS_PLAN_NO: '計劃編號',
  SEQ_NO: '序號',
  // ...
}

// 建表頭
const ws = XLSX.utils.json_to_sheet(
  dataToExport.map(row => {
    const o = {}
    headerOrder.forEach(k => o[humanReadableMap[k] || k] = row[k])
    return o
  }),
  { header: headerOrder.map(k => humanReadableMap[k] || k) }
)

(E) 合併前的檢查原則

  • 合併只用在「同一欄位,同一群組,值完全一致、且確定同筆資料需要視覺合併」的情況。

  • 你的分組鍵(例如 APS_PLAN_NOSEQ_NO)要先排序再逐段合併,避免跨組誤合。

  • 鍵名必須對齊資料,不要用展示用(中文)鍵合併




六、對照檢查:為什麼 inx0728 正常、我匯出的不正常?

  • 欄位名差異:inx0728 使用原始英文鍵,匯出檔是中文展示鍵;若你的匯出流程用中文鍵去找值,會拿不到。

  • 排序/分組鍵:inx0728 的排序與分組很單純;你的頁面因為篩選與合併(尤其跨欄位群組),容易誤把不同筆當相同而合併。

  • 篩選殘留:inx0728 是原始全集,你的頁面可能保留了 Pinia 全域篩選或搜尋字串。




七、常見陷阱與避免法

  • 陷阱:先清空 Pinia filter,再跑初始化 → 變成所有欄位沒值。
    避免:先建好 uniqueValuesfilters,最後才回存 Pinia。

  • 陷阱:用 Object.keys(映射表)tableHeaders
    避免:用資料實際鍵名決定 header,再用映射表只負責顯示文字

  • 陷阱:合併前未排序或未分組。
    避免:先依分組鍵排序,再逐段計算 rowSpan/merges。




八、最小可行範例(先確定筆數正確)

import * as XLSX from 'xlsx'

// 1) 不吃篩選,直接匯出完整資料
const dataToExport = mappedTableData.value

// 2) 不做任何合併與格式,只驗證筆數與欄位
const ws = XLSX.utils.json_to_sheet(dataToExport)

// 3) 可選:指定 header 順序,對齊參考檔
const headerOrder = ['APS_VERSION','APS_PLAN_NO','SEQ_NO','SITE','APPL','CUSTOMER_NAME','PRODUCT_ID']
const ws2 = XLSX.utils.json_to_sheet(
  dataToExport.map(row => {
    const o = {}
    headerOrder.forEach(k => o[k] = row[k])
    return o
  }),
  { header: headerOrder }
)

// 4) 生成活頁簿
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws2, '考題結論')

// 5) 後面再一段段加回格式/合併



九、上線前檢查清單(Checklist)

  • 匯出來源是 mappedTableData(想完整 → YES;只匯篩選結果 → filteredData

  • 初始化順序:先建 uniqueValues/filters,後存 Pinia

  • tableHeaders 取自資料鍵名,映射只做顯示

  • 合併前先排序、正確分組;合併鍵名對齊資料鍵

  • 無合併版匯出筆數與 inx0728 完全一致

  • 逐步加回格式與合併,每一步都重新驗證筆數




FAQ

Q:為什麼畫面筆數跟匯出筆數不同?
A:因為你用 filteredData 匯出,或 Pinia 有殘留篩選。請先印出兩個長度確認。

Q:為什麼看起來有重複?
A:多半是合併(rowSpans/merges)把不同列壓在一起。先停用合併,驗證筆數後再逐步加回。

Q:欄位對不上 inx0728?
A:inx0728 用英文鍵名,匯出檔是中文標題。請用資料鍵名建表頭,最後再映射成中文顯示。




結語

匯出 Excel 筆數異常,99% 都是流程的小細節出錯——篩選來源、初始化順序、表頭映射、合併條件
用「先純匯出 → 再加格式 → 最後合併」這個節奏,每一步驗證筆數,你就能快速鎖定問題、穩定上線。

留言

這個網誌中的熱門文章

🔍Vue.js 專案錯誤排查:解決 numericFields is not defined 與合併儲存格邏輯最佳化

🔎EF Core 連 Oracle 出現 ORA-00600 [kpp_concatq:2] 的完整排錯指南(含 EF Core ToString/CultureInfo 錯誤)

🛠【ASP.NET Core + Oracle】解決 ORA-00904 "FALSE": 無效的 ID 錯誤與資料欄位動態插入顯示問題