Initial commit

This commit is contained in:
Viet An
2026-04-06 13:47:10 +07:00
commit f423d9ab20
439 changed files with 97497 additions and 0 deletions

View 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,
},
}
})