🧾修復 Vue + SheetJS 匯出表頭錯位:讓「評估明細_LCM」回到 Excel 抬頭
- 取得連結
- X
- 以電子郵件傳送
- 其他應用程式
內容
摘要
使用
Vue 3 + SheetJS (xlsx)
匯出 Excel 時,如果第一列的群組表頭與
!merges
的合併範圍沒有一一對齊,Excel 會以合併區塊左上角儲存格當作標題,導致像「評估明細_LCM」這樣的抬頭被前一組覆蓋、看起來「消失」。本文用完整重寫(與你現有實作完全不同、但輸出格式相同)的範例程式碼示範如何修正,並提供可維護的設定化做法。
問題與成因(為什麼 LCM 抬頭會不見?)
-
Excel 的合併儲存格以左上角為主標。
-
若你把「LCM 群組」的合併區設在第 21~25 欄,但
groupRow[21]
仍顯示上一組(例如 TFT),Excel 就會把整塊 21~25 顯示成 TFT,LCM 自然不會出現。 -
解法就是:群組標籤的欄位索引要與合併範圍完全一致。
設計原則
-
設定化群組:不要散落「魔法數字」;把群組區間集中管理。
-
單一真實來源:
groupRow
與!merges
同步由設定產生。 -
數值欄位一致處理:轉數字、空白不轉、千分位格式化。
-
合併策略可讀:
-
前 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 件)
-
群組邊界一致:
bands
的start..end
是否與!merges
完全一致? -
群組左上角:每個橫向合併區塊左上角的
groupHeader
文字是否正確? -
固定欄縱向合併:前 14 欄是否確實做了兩列的縱向合併?
-
數值欄位:僅對 number 型別套
#,##0
;空白維持空白。 -
內容合併策略:
TFT5
是否按TFT4
分群合併,其它欄位按相等值合併?
結語
當 Excel 的群組表頭「憑空消失」時,通常不是 SheetJS 的問題,而是群組標籤索引與合併範圍未對齊。把「群組定義」變成唯一設定來源,所有表頭文字與合併都自動由它生成,才能從根本避免錯位。以上的全新重寫既可讀、又好維護,且輸出與你既有格式相同——「評估明細_LCM」會乖乖回到它的位置。
留言
張貼留言