🧾修復 Vue + SheetJS 匯出表頭錯位:讓「評估明細_LCM」回到 Excel 抬頭

 

內容

摘要

使用 Vue 3 + SheetJS (xlsx) 匯出 Excel 時,如果第一列的群組表頭!merges 的合併範圍沒有一一對齊,Excel 會以合併區塊左上角儲存格當作標題,導致像「評估明細_LCM」這樣的抬頭被前一組覆蓋、看起來「消失」。本文用完整重寫(與你現有實作完全不同、但輸出格式相同)的範例程式碼示範如何修正,並提供可維護的設定化做法。


問題與成因(為什麼 LCM 抬頭會不見?)

  • Excel 的合併儲存格以左上角為主標。

  • 若你把「LCM 群組」的合併區設在第 21~25 欄,但 groupRow[21] 仍顯示上一組(例如 TFT),Excel 就會把整塊 21~25 顯示成 TFT,LCM 自然不會出現

  • 解法就是:群組標籤的欄位索引要與合併範圍完全一致。


設計原則

  1. 設定化群組:不要散落「魔法數字」;把群組區間集中管理。

  2. 單一真實來源groupRow!merges 同步由設定產生。

  3. 數值欄位一致處理:轉數字、空白不轉、千分位格式化。

  4. 合併策略可讀

    • 前 14 欄:縱向合併兩列(群組列 + 子標列)。

    • 之後各群組:依設定橫向合併(第一列)。

    • 內容區:

      • 一般欄位:連續相等值合併。

      • TFT5:在同一個 APS_PLAN_NO 區段內,TFT4 分群合併。


問題程式片段(示意的錯誤寫法,用來解釋邊界不對齊)

注意:以下為重新撰寫的示意錯誤碼,僅為說明概念;你的原始程式。


// 群組列(錯誤示意):索引邏輯沒有與實際 merges 對齊
const makeGroupRow = (headers: string[]) => headers.map((_, i) => { if (i < 14) return '基礎欄位' if (i < 16) return '考題結論' // 14..15 if (i < 22) return '評估明細_TFT' // 16..21 ← 問題:吃到 21 if (i < 26) return '評估明細_LCM' // 22..25 return '評估明細_Material' }) // 合併(正確的 LCM 範圍其實是 21..25) const merges = [ { s:{ r:0, c:14 }, e:{ r:0, c:15 } }, // 考題結論 { s:{ r:0, c:16 }, e:{ r:0, c:20 } }, // TFT { s:{ r:0, c:21 }, e:{ r:0, c:25 } }, // LCM ← 與上面的 groupRow 邏輯不一致 ]

上面示意中,LCM 合併是 21..25,但 groupRow 在 21 仍標示為 TFT,Excel 會以 (r:0,c:21) 的值(TFT)當整塊 21..25 的抬頭,於是你看不到 LCM。


全新範例程式碼(完全不同實作,但輸出格式相同

特色:
  • schema 單一設定源驅動表頭文字合併區,避免不一致
  • 內容合併抽象化:連續值合併 vs 依鍵值分段合併(TFT5 依 TFT4)
  • 一次處理數值欄位轉型與千分位
// ======= 共用小工具 ======= const asNumber = (val: unknown) => { if (val === '' || val === null || val === undefined) return '' const n = Number(String(val).replace(/,/g, '')) return Number.isFinite(n) ? n : '' } const clone = <T>(x: T): T => JSON.parse(JSON.stringify(x)) // ======= 匯出主流程(重寫版) ======= function exportToExcel() { // 0) 來源資料 const src = rowSpans.value?.rows ?? [] if (!src.length) { alert('沒有資料可匯出'); return } // 1) 以單一設定源描述版面 const schema = { lockTopCols: 14, // 0..13 會做兩列縱向合併 bands: [ { text: '考題結論', start: 14, end: 15 }, { text: '評估明細_TFT', start: 16, end: 20 }, { text: '評估明細_LCM', start: 21, end: 25 }, { text: '評估明細_Material', start: 26, end: 28 }, ], numberFields: new Set([ 'APS_PLAN_LIST','APS_PLAN_SEQ', 'DEMAND_QTY','DEAL_QTY', 'TFT2','TFT5', 'LCM2','LCM5', 'MATERIAL2', ]), keyAps: 'APS_PLAN_NO', keyTft4: 'TFT4', colTft5: 'TFT5', } const headers: string[] = clone(tableHeaders.value) // 2) 產生兩列表頭 const groupHeader = buildBandHeader(headers, schema) const subHeader = headers.map(h => uppercaseMapping[h] || h) // 3) 轉二維資料(含數值型態轉換與 TFT5 特例) const body = src.map(rec => headers.map(h => { if (h === schema.colTft5) return rec[h] === '' ? '' : asNumber(rec[h]) return schema.numberFields.has(h) ? asNumber(rec[h]) : (rec[h] ?? '') })) // 4) 建立合併設定(表頭 + 內容) const merges: any[] = [] // 4-1) 固定欄位縱向合併兩列 for (let c = 0; c < schema.lockTopCols; c++) { merges.push({ s:{ r:0, c }, e:{ r:1, c } }) } // 4-2) 橫向群組合併(第一列) for (const b of schema.bands) { merges.push({ s:{ r:0, c:b.start }, e:{ r:0, c:b.end } }) } // 4-3) 內容合併 const idxAps = headers.indexOf(schema.keyAps) const idxTft4 = headers.indexOf(schema.keyTft4) const idxTft5 = headers.indexOf(schema.colTft5) // 以 APS_PLAN_NO 區段處理 let from = 0 while (from < body.length) { const aps = body[from][idxAps] let to = from while (to < body.length && body[to][idxAps] === aps) to++ // 在 [from, to) 範圍內對每欄進行合併 for (let col = 0; col < headers.length; col++) { const isNum = schema.numberFields.has(headers[col]) if (col === idxTft5) { // TFT5:依 TFT4 切段後直接合併(不看值是否相等) mergeByBucket(body, idxTft4, from, to - 1, col, merges, isNum) } else { // 其他欄:連續相等值合併 mergeSameRuns(body, from, to - 1, col, merges, isNum) } } from = to } // 5) 做出工作表 const ws = XLSX.utils.aoa_to_sheet([groupHeader, subHeader, ...body]) ws['!merges'] = merges // 6) 套千分位(只處理 number) headers.forEach((h, ci) => { if (!schema.numberFields.has(h)) return const letter = XLSX.utils.encode_col(ci) for (let r = 2; r < body.length + 2; r++) { const addr = `${letter}${r}` const cell = ws[addr] if (cell && typeof cell.v === 'number') { cell.t = 'n' cell.z = '#,##0' } } }) // 7) 匯出 const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, 'NB急單計劃排程結果 - 考題結論') const bin = XLSX.write(wb, { bookType:'xlsx', type:'array' }) const blob = new Blob([bin], { type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) saveAs(blob, `NB急單計劃排程結果 - 考題結論_${selectedVersion.value}.xlsx`) } // ======= 表頭建構(群組第一列) ======= function buildBandHeader(headers: string[], schema: any) { const row = headers.map((h, i) => i < schema.lockTopCols ? (uppercaseMapping[h] || h) : '') for (const b of schema.bands) { for (let c = b.start; c <= b.end; c++) row[c] = b.text } return row } // ======= 內容合併:連續相等值合併 ======= function mergeSameRuns(matrix: any[][], rStart: number, rEnd: number, col: number, merges: any[], isNumeric: boolean) { let a = rStart while (a <= rEnd) { let b = a + 1 while (b <= rEnd && matrix[b][col] === matrix[a][col]) b++ if (b - a > 1) { if (isNumeric) for (let r = a + 1; r < b; r++) matrix[r][col] = '' merges.push({ s:{ r:a + 2, c:col }, e:{ r:b - 1 + 2, c:col } }) } a = b } } // ======= 內容合併:依鍵欄分段(TFT5 依 TFT4) ======= function mergeByBucket(matrix: any[][], keyCol: number, rStart: number, rEnd: number, targetCol: number, merges: any[], isNumeric: boolean) { let a = rStart while (a <= rEnd) { const key = matrix[a][keyCol] let b = a + 1 while (b <= rEnd && matrix[b][keyCol] === key) b++ if (b - a > 1) { if (isNumeric) for (let r = a + 1; r < b; r++) matrix[r][targetCol] = '' merges.push({ s:{ r:a + 2, c:targetCol }, e:{ r:b - 1 + 2, c:targetCol } }) } a = b } }

這份重寫與你原本不同在哪?

  • 單一定義 schema 驅動表頭與合併,移除多組 if (idx < N);不會再有邊界錯配。

  • 內容合併策略以兩個工具函式抽象:mergeSameRuns(相等值連續)與 mergeByBucket(依鍵值分段)。

  • 數值欄位與 TFT5 特例集中在轉型階段處理,便於維護。


Debug 清單(出包先檢這 5 件)

  1. 群組邊界一致bandsstart..end 是否與 !merges 完全一致?

  2. 群組左上角:每個橫向合併區塊左上角的 groupHeader 文字是否正確?

  3. 固定欄縱向合併:前 14 欄是否確實做了兩列的縱向合併?

  4. 數值欄位:僅對 number 型別套 #,##0;空白維持空白。

  5. 內容合併策略TFT5 是否按 TFT4 分群合併,其它欄位按相等值合併?


結語

當 Excel 的群組表頭「憑空消失」時,通常不是 SheetJS 的問題,而是群組標籤索引與合併範圍未對齊。把「群組定義」變成唯一設定來源,所有表頭文字與合併都自動由它生成,才能從根本避免錯位。以上的全新重寫既可讀、又好維護,且輸出與你既有格式相同——「評估明細_LCM」會乖乖回到它的位置。

留言

這個網誌中的熱門文章

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

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

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