🧾Excel 合併儲存格為何不會自動加總?用 SheetJS(XLSX)正確輸出合併與數值格式的完整指南

 

摘要(SEO 重點)

很多人把前端資料匯出成 Excel 後,做了「合併儲存格」卻發現:合併後只顯示一個數字,沒有像狀態列「加總/平均」那樣自動統計,導致畫面顯示與底部加總不一致。本文用 軟體工程師 的角度說清楚 Excel 合併的本質、在 SheetJS(XLSX) 中如何處理兩種需求:

  1. 合併後要顯示小計/總和;2) 合併後只顯示其中一個值
    文末給你可直接套用的程式碼範例與常見坑位清單。




一、觀念釐清:Excel 合併≠加總

  • 合併儲存格只保留左上角(Top-Left)那格的值,其餘被合併的格子值會被隱藏(並非相加)。

  • Excel 視窗底部的狀態列「總和/平均/計數」是針對選取範圍的原始值計算,與你合併後顯示的單一值無關。
    👉 因此,若要在合併格顯示加總,必須在程式中自己算好,寫回合併起始格。




二、專案背景(情境)

  • 前端:Vue + SheetJS(xlsx

  • 需求:

    • 多層表頭(群組/子表頭)

    • 匯出的數值要有千分位(但儲存為數值,Excel 仍可計算)

    • 依特定欄位群組合併儲存格(例如同一 APS_PLAN_NO 內、相同值連續列合併)

    • 合併後的數值欄:要嘛顯示總和,要嘛只顯示其中一個




三、資料型別與格式:數值要「真數字」

把千分位字串還原成數字,並設定 Excel 格式(#,##0),讓顯示可計算兼得:

// 數值欄位 const numericFields = [ 'APS_PLAN_LIST', 'APS_PLAN_SEQ', 'DEMAND_QTY', 'DEAL_QTY', 'TFT2', 'TFT5', 'LCM2', 'LCM5', 'MATERIAL2', ] // AOA -> Sheet 後,將數值欄設為數字與千分位格式 tableHeaders.value.forEach((k, colIdx) => { if (!numericFields.includes(k)) return const colLetter = XLSX.utils.encode_col(colIdx) for (let row = 2; row <= bodyRows.length + 1; row++) { const addr = `${colLetter}${row}` const cell = ws[addr] if (cell && typeof cell.v === 'number') { cell.t = 'n' cell.z = '#,##0' } } })



四、做「加總」的兩種實作

方案 A:合併前先把加總寫進最上方那格

適用:需要把一段相同值(連續列)的數值加總後,顯示在合併區塊。

const planNoIdx = tableHeaders.value.indexOf('APS_PLAN_NO') while (start < bodyRows.length) { // 在同一個 APS_PLAN_NO 的區段內處理 let end = start + 1 while (end < bodyRows.length && bodyRows[end][planNoIdx] === bodyRows[start][planNoIdx]) end++ // 每一欄逐段檢查連續相同值 for (let col = 0; col < tableHeaders.value.length; col++) { let i = start while (i < end) { let j = i + 1 while (j < end && bodyRows[j][col] === bodyRows[i][col]) j++ if (j - i > 1) { const key = tableHeaders.value[col] // ✅ 數值欄位:先加總,再清空後續列 if (numericFields.includes(key)) { const total = bodyRows .slice(i, j) .reduce((acc, row) => acc + (row[col] || 0), 0) bodyRows[i][col] = total for (let k = i + 1; k < j; k++) bodyRows[k][col] = '' } // 記下合併範圍(2 表示前面有兩列表頭) merges.push({ s: { r: i + 2, c: col }, e: { r: j - 1 + 2, c: col } }) } i = j } } start = end }

方案 B:合併後再掃描 ws['!merges'] 做加總

適用:想保持 bodyRows 原狀,所有處理都在 Worksheet 階段完成。

merges.forEach(({ s, e }) => { // 僅處理同欄的垂直合併 if (s.c === e.c && e.r > s.r) { const colIdx = s.c const key = tableHeaders.value[colIdx] if (!numericFields.includes(key)) return let sum = 0 for (let rr = s.r; rr <= e.r; rr++) { const addr = `${XLSX.utils.encode_col(colIdx)}${rr + 1}` const cell = ws[addr] if (cell && typeof cell.v === 'number') sum += cell.v } const topAddr = `${XLSX.utils.encode_col(colIdx)}${s.r + 1}` ws[topAddr].v = sum ws[topAddr].t = 'n' ws[topAddr].z = '#,##0' } })



五、不要加總,只顯示其中一個值(例如第一筆/最後一筆/非 0/最大值)

把要保留的值寫進合併區塊的第一格,其餘清空:

if (j - i > 1) { const key = tableHeaders.value[col] // ▼ 依需求挑一條保留規則(擇一) // 1) 第一筆: const keep = bodyRows[i][col] ?? 0 // 2) 最後一筆: // const keep = bodyRows[j - 1][col] ?? 0 // 3) 第一個非 0: // const keep = bodyRows.slice(i, j).map(r => r[col]).find(v => Number(v) !== 0) ?? 0 // 4) 最大值: // const keep = Math.max(...bodyRows.slice(i, j).map(r => Number(r[col] || 0))) if (numericFields.includes(key)) { bodyRows[i][col] = keep for (let k = i + 1; k < j; k++) bodyRows[k][col] = '' } merges.push({ s: { r: i + 2, c: col }, e: { r: j - 1 + 2, c: col } }) }



六、reduce 加總語法(讀得懂就能改得動)

const total = bodyRows .slice(i, j) // 取 i..(j-1) .reduce((acc, row) => acc + (row[col] || 0), 0) // 把第 col 欄位相加
  • acc:累加器,初始值 0

  • row[col] || 0:若空值就當 0

  • 回傳的 total 就是這段的總和




七、常見坑位 & 檢查清單

  1. 字串千分位→數值"12,345" 必須 parseFloat(v.replace(/,/g, ''))

  2. Excel 顯示格式cell.t='n' 搭配 cell.z='#,##0',顯示千分位仍保留「真數字」。

  3. 表頭位移:AOA 前面有兩列表頭,所以合併座標需 +2

  4. 只清空顯示列:合併區塊內被隱藏的列,請設空字串,避免數字殘留造成視覺誤解。

  5. 群組邏輯:先依主鍵(如 APS_PLAN_NO)劃分區段,再在區段內做「連續相同值」的合併。

  6. 效能:大檔案請先排序、一次掃描,避免重複走訪。




八、可直接套用的精簡匯出流程(節錄)

// 1) 準備 bodyRows(字串千分位→數值) const bodyRows = filteredData.value.map(row => tableHeaders.value.map(key => { let v = row[key] ?? '' if (numericFields.includes(key) && typeof v === 'string') { v = parseFloat(v.replace(/,/g, '')) || 0 } return v }) ) // 2) 建合併範圍 &(選一)加總或保留值 const merges = [] const planNoIdx = tableHeaders.value.indexOf('APS_PLAN_NO') let start = 0 while (start < bodyRows.length) { let end = start + 1 while (end < bodyRows.length && bodyRows[end][planNoIdx] === bodyRows[start][planNoIdx]) end++ for (let col = 0; col < tableHeaders.value.length; col++) { let i = start while (i < end) { let j = i + 1 while (j < end && bodyRows[j][col] === bodyRows[i][col]) j++ if (j - i > 1) { // ► A.加總: const total = bodyRows.slice(i, j).reduce((acc, r) => acc + (r[col] || 0), 0) bodyRows[i][col] = total for (let k = i + 1; k < j; k++) bodyRows[k][col] = '' // ► B.只保留一個值(若改此路徑,請註解 A) // const keep = bodyRows[i][col] ?? 0 // bodyRows[i][col] = keep // for (let k = i + 1; k < j; k++) bodyRows[k][col] = '' merges.push({ s: { r: i + 2, c: col }, e: { r: j - 1 + 2, c: col } }) } i = j } } start = end } // 3) 產生工作表、套用合併與數字格式 const ws = XLSX.utils.aoa_to_sheet([groupRow, subHeaderRow, ...bodyRows]) ws['!merges'] = merges tableHeaders.value.forEach((k, colIdx) => { if (!numericFields.includes(k)) return const colLetter = XLSX.utils.encode_col(colIdx) for (let row = 2; row <= bodyRows.length + 1; row++) { const addr = `${colLetter}${row}` const cell = ws[addr] if (cell && typeof cell.v === 'number') { cell.t = 'n' cell.z = '#,##0' } } })



九、結論

  • Excel 合併不會自動加總,它只顯示合併區塊左上角那格的值。

  • 你可以在 合併前(或 合併後掃描 merges)自行計算總和,寫回第一格;或是只保留某一個值。

  • 記得確保「真數字 + 顯示格式」,讓報表看起來正確可被計算

若你需要把「第一筆/最後一筆/非 0/最大值」等規則做成可配置,只要把上面的 keep 選擇抽成參數即可,報表需求變更時就不用改核心流程。

留言

這個網誌中的熱門文章

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

🛠【實戰排除教學】從 VS Code 的 _logger 錯誤,到 PowerShell 找不到 npm/serve,再到 Oracle ORA-03135 連線中斷——一次搞懂!

🔎如何在 Oracle PL/SQL 儲存過程中為文字欄位加入換行符號(CHR(10))——以 Updlcmremark 為例