Initial commit
This commit is contained in:
323
app/plugins/excelExporter.client.js
Normal file
323
app/plugins/excelExporter.client.js
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* plugins/excelExporter.js
|
||||
*
|
||||
* Xuất Excel từ DataTable với đầy đủ:
|
||||
* - Group header (2 dòng, colspan/rowspan merge)
|
||||
* - Stack split (array → nhiều sub-row, mỗi item 1 dòng)
|
||||
* - splitTemplate, splitExpression, splitField, splitType currency
|
||||
* - Template: FormatNumber, dayjs date, điều kiện row.type===x, Math.abs diff
|
||||
* - Lọc cột action (DebtCheckbox, SvgIcon, modal...)
|
||||
* - Màu header từ headerStyle / tablesetting
|
||||
*
|
||||
* Không cần cài thêm package — SheetJS load từ CDN lần đầu dùng (lazy).
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Import SheetJS từ package đã cài (npm install xlsx)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
import * as _XLSX from 'xlsx'
|
||||
|
||||
async function getXLSX() {
|
||||
return _XLSX
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function stripHtml(str) {
|
||||
if (!str) return ''
|
||||
return String(str).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function fmtCurrency(v) {
|
||||
if (v === null || v === undefined || v === '') return ''
|
||||
const n = Number(v)
|
||||
if (isNaN(n)) return String(v)
|
||||
return n.toLocaleString('vi-VN')
|
||||
}
|
||||
|
||||
function fmtDate(raw, fmt = 'DD/MM/YYYY') {
|
||||
if (!raw) return ''
|
||||
try {
|
||||
const d = new Date(raw)
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
return fmt.replace('DD', dd).replace('MM', mm).replace('YYYY', String(d.getFullYear()))
|
||||
} catch { return String(raw) }
|
||||
}
|
||||
|
||||
function resolveSplitTemplate(template, item) {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key) => item[key] ?? '')
|
||||
}
|
||||
|
||||
function evalSplitExpression(expression, item) {
|
||||
try {
|
||||
const expr = expression.replace(/\{(\w+)\}/g, (_, key) => {
|
||||
const v = item[key]
|
||||
return (v === null || v === undefined) ? 'null' : Number(v)
|
||||
})
|
||||
// eslint-disable-next-line no-new-func
|
||||
return Function('"use strict"; return (' + expr + ')')()
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Resolve text của 1 item trong stack array
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getStackItemText(field, item) {
|
||||
if (field.splitExpression) {
|
||||
const val = evalSplitExpression(field.splitExpression, item)
|
||||
if (val === null || val === undefined) return '—'
|
||||
return field.splitType === 'currency' ? fmtCurrency(val) : String(val)
|
||||
}
|
||||
if (field.splitTemplate) return resolveSplitTemplate(field.splitTemplate, item)
|
||||
const val = item[field.splitField]
|
||||
if (val === null || val === undefined) return '—'
|
||||
return field.splitType === 'currency' ? fmtCurrency(val) : String(val)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Resolve text của 1 cell bình thường
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getCellText(field, row) {
|
||||
if (field.name === '__stt') return row.__stt ?? ''
|
||||
|
||||
if (field.split === 'stack') {
|
||||
const arr = row[field.name]
|
||||
if (!Array.isArray(arr) || arr.length === 0) return '—'
|
||||
// Gom tất cả item thành 1 chuỗi ngăn cách bởi newline
|
||||
return arr.map(item => getStackItemText(field, item)).join('\n')
|
||||
}
|
||||
|
||||
if (field.template) return evalTemplate(field.template, row)
|
||||
|
||||
if (field.format === 'date' && row[field.name]) return fmtDate(row[field.name])
|
||||
|
||||
if (field.format === 'number' || field.type === 'currency') {
|
||||
return row[field.name] !== null && row[field.name] !== undefined
|
||||
? fmtCurrency(row[field.name]) : ''
|
||||
}
|
||||
|
||||
return row[field.name] ?? ''
|
||||
}
|
||||
|
||||
function evalTemplate(template, row) {
|
||||
// <FormatNumber v-bind="{value: row.xxx}" />
|
||||
const fnMatch = template.match(/FormatNumber[^}]*value:\s*row\.(\w+)/)
|
||||
if (fnMatch) {
|
||||
const v = row[fnMatch[1]]
|
||||
return v === null || v === undefined ? '' : fmtCurrency(v)
|
||||
}
|
||||
|
||||
// row.type === N ? row.xxx : null
|
||||
const condMatch = template.match(/row\.(\w+)\s*===\s*(\d+)\s*\?\s*row\.(\w+)\s*:\s*null/)
|
||||
if (condMatch) {
|
||||
const v = row[condMatch[1]] === Number(condMatch[2]) ? row[condMatch[3]] : null
|
||||
return v === null || v === undefined ? '' : fmtCurrency(v)
|
||||
}
|
||||
|
||||
// $dayjs(row.xxx).format('...')
|
||||
const djMatch = template.match(/\$dayjs\(row\.(\w+)\)\.format\(['"]([^'"]+)['"]\)/)
|
||||
if (djMatch) return fmtDate(row[djMatch[1]], djMatch[2])
|
||||
|
||||
// Math.abs($dayjs().startOf('day').diff(row.xxx, 'day'))
|
||||
const diffMatch = template.match(/Math\.abs\(.*\.diff\(row\.(\w+),\s*'day'\)\)/)
|
||||
if (diffMatch) {
|
||||
try {
|
||||
const d = new Date(row[diffMatch[1]]); d.setHours(0, 0, 0, 0)
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0)
|
||||
return String(Math.abs(Math.round((today - d) / 86400000)))
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
// {{row.xxx}} nội suy đơn giản
|
||||
const interpolated = template.replace(/\{\{row\.(\w+)\}\}/g, (_, k) => row[k] ?? '')
|
||||
return stripHtml(interpolated)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Xây dựng group header row (mirror DataTable.vue groupHeaderRow computed)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildGroupHeaderRow(fields) {
|
||||
const result = []
|
||||
let i = 0
|
||||
while (i < fields.length) {
|
||||
const f = fields[i]
|
||||
if (!f.groupHeader) {
|
||||
result.push({ label: stripHtml(f.label), colspan: 1, rowspan: 2, isGroup: false })
|
||||
i++
|
||||
} else {
|
||||
let count = 1
|
||||
while (i + count < fields.length && fields[i + count].groupHeader === f.groupHeader) count++
|
||||
result.push({ label: f.groupHeader, colspan: count, rowspan: 1, isGroup: true })
|
||||
i += count
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Style helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function hexFromStyle(headerStyle) {
|
||||
const m = (headerStyle || '').match(/background-color:\s*#([0-9a-fA-F]{6})/i)
|
||||
return m ? m[1].toUpperCase() : '204853'
|
||||
}
|
||||
|
||||
function thinBorder() {
|
||||
const s = { style: 'thin', color: { rgb: 'FFD0D0D0' } }
|
||||
return { top: s, bottom: s, left: s, right: s }
|
||||
}
|
||||
|
||||
function headerCellStyle(bgHex, fgHex) {
|
||||
return {
|
||||
fill: { patternType: 'solid', fgColor: { rgb: 'FF' + bgHex } },
|
||||
font: { bold: true, color: { rgb: 'FF' + fgHex }, sz: 10, name: 'Arial' },
|
||||
alignment: { horizontal: 'center', vertical: 'center', wrapText: true },
|
||||
border: thinBorder(),
|
||||
}
|
||||
}
|
||||
|
||||
function dataCellStyle(alignRight, wrapText) {
|
||||
return {
|
||||
font: { sz: 10, name: 'Arial' },
|
||||
alignment: { horizontal: alignRight ? 'right' : 'left', vertical: 'top', wrapText },
|
||||
border: thinBorder(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Hàm xuất Excel chính
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function exportToExcel({ fields, data, tablesetting = [], filename = 'export' }) {
|
||||
const XLSX = await getXLSX()
|
||||
|
||||
const hasGroup = fields.some(f => f.groupHeader)
|
||||
const HEADER_ROWS = hasGroup ? 2 : 1
|
||||
|
||||
const fgHex = (tablesetting.find(s => s.code === 'header-font-color')?.detail || '#FFFFFF')
|
||||
.replace('#', '').toUpperCase()
|
||||
|
||||
// ── Xây dựng AOA ──────────────────────────────────────────────────────────
|
||||
const aoa = []
|
||||
|
||||
if (hasGroup) {
|
||||
const groupRow = buildGroupHeaderRow(fields)
|
||||
const row1 = []
|
||||
const row2 = []
|
||||
let colCursor = 0
|
||||
|
||||
groupRow.forEach(col => {
|
||||
if (!col.isGroup) {
|
||||
row1.push(col.label)
|
||||
row2.push('') // merge về row1 sau
|
||||
colCursor++
|
||||
} else {
|
||||
row1.push(col.label)
|
||||
for (let x = 1; x < col.colspan; x++) row1.push('')
|
||||
const groupFields = fields.slice(colCursor, colCursor + col.colspan)
|
||||
groupFields.forEach(f => row2.push(stripHtml(f.label)))
|
||||
colCursor += col.colspan
|
||||
}
|
||||
})
|
||||
aoa.push(row1)
|
||||
aoa.push(row2)
|
||||
} else {
|
||||
aoa.push(fields.map(f => stripHtml(f.label)))
|
||||
}
|
||||
|
||||
// ── Dữ liệu ────────────────────────────────────────────────────────────────
|
||||
data.forEach((row, idx) => {
|
||||
if (row.__stt === undefined) row.__stt = idx + 1
|
||||
|
||||
// Đếm số sub-row cần (bằng max length của các stack field)
|
||||
const maxStack = Math.max(1, ...fields.map(f =>
|
||||
f.split === 'stack' && Array.isArray(row[f.name]) ? (row[f.name].length || 1) : 1
|
||||
))
|
||||
|
||||
for (let si = 0; si < maxStack; si++) {
|
||||
aoa.push(fields.map(f => {
|
||||
// Cột stack: lấy item[si]
|
||||
if (f.split === 'stack') {
|
||||
const arr = Array.isArray(row[f.name]) ? row[f.name] : []
|
||||
const item = arr[si]
|
||||
return item ? getStackItemText(f, item) : ''
|
||||
}
|
||||
// Cột thường: chỉ ghi ở sub-row đầu
|
||||
return si === 0 ? String(getCellText(f, row) ?? '') : ''
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// ── Worksheet ──────────────────────────────────────────────────────────────
|
||||
const ws = XLSX.utils.aoa_to_sheet(aoa)
|
||||
|
||||
// ── Merge cells ────────────────────────────────────────────────────────────
|
||||
if (hasGroup) {
|
||||
const merges = []
|
||||
const groupRow = buildGroupHeaderRow(fields)
|
||||
let c = 0
|
||||
groupRow.forEach(col => {
|
||||
if (!col.isGroup) {
|
||||
merges.push({ s: { r: 0, c }, e: { r: 1, c } }) // rowspan=2
|
||||
c++
|
||||
} else {
|
||||
if (col.colspan > 1)
|
||||
merges.push({ s: { r: 0, c }, e: { r: 0, c: c + col.colspan - 1 } }) // colspan
|
||||
c += col.colspan
|
||||
}
|
||||
})
|
||||
ws['!merges'] = merges
|
||||
}
|
||||
|
||||
// ── Độ rộng cột ────────────────────────────────────────────────────────────
|
||||
ws['!cols'] = fields.map(f => {
|
||||
const labelLen = stripHtml(f.label).length
|
||||
const minWch = Math.max(f.minwidth ? f.minwidth / 7 : 10, labelLen + 2)
|
||||
const maxWch = f.maxwidth ? f.maxwidth / 7 : 40
|
||||
return { wch: Math.min(minWch, maxWch) }
|
||||
})
|
||||
|
||||
// ── Chiều cao dòng header ──────────────────────────────────────────────────
|
||||
ws['!rows'] = Array.from({ length: HEADER_ROWS }, () => ({ hpt: 36 }))
|
||||
|
||||
// ── Style từng cell ────────────────────────────────────────────────────────
|
||||
const wsRange = XLSX.utils.decode_range(ws['!ref'])
|
||||
for (let R = wsRange.s.r; R <= wsRange.e.r; R++) {
|
||||
for (let C = wsRange.s.c; C <= wsRange.e.c; C++) {
|
||||
const addr = XLSX.utils.encode_cell({ r: R, c: C })
|
||||
if (!ws[addr]) ws[addr] = { t: 's', v: '' }
|
||||
const f = fields[C]
|
||||
if (R < HEADER_ROWS) {
|
||||
ws[addr].s = headerCellStyle(hexFromStyle(f?.headerStyle), fgHex)
|
||||
} else {
|
||||
const isRight = f?.textalign === 'right' || f?.splitType === 'currency'
|
||||
const hasStack = f?.split === 'stack'
|
||||
ws[addr].s = dataCellStyle(isRight, hasStack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ghi file ───────────────────────────────────────────────────────────────
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Data')
|
||||
XLSX.writeFile(wb, `${filename}.xlsx`)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Nuxt Plugin
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
return {
|
||||
provide: {
|
||||
exportTableExcel: exportToExcel,
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user