|
|
@@ -42,14 +42,27 @@ data class ExportSheet<T : Any>(
|
|
|
val sheetName: String? = null,
|
|
|
val data: List<T>,
|
|
|
val customHeaders: LinkedHashMap<String, String>? = null,
|
|
|
- // 可选:按字段名进行值转换(先转再格式化)
|
|
|
- val valueMappers: Map<String, (Any?) -> Any?> = emptyMap()
|
|
|
+ val valueMappers: Map<String, (Any?) -> Any?> = emptyMap(),
|
|
|
+ val modelClass: KClass<out Any>? = null, // 空数据时也能拿到列定义
|
|
|
+ val skipIfEmpty: Boolean = true, // 默认:跳过空 sheet
|
|
|
+ val emptyPlaceholder: String = "— no data —" // 不跳过时的占位文本
|
|
|
)
|
|
|
|
|
|
+
|
|
|
inline fun <reified T : Any> List<T>.toExportSheet(
|
|
|
sheetNameOverride: String? = null,
|
|
|
- valueMappers: Map<String, (Any?) -> Any?> = emptyMap()
|
|
|
-): ExportSheet<T> = ExportSheet(sheetNameOverride, this, null, valueMappers)
|
|
|
+ valueMappers: Map<String, (Any?) -> Any?> = emptyMap(),
|
|
|
+ skipIfEmpty: Boolean = true,
|
|
|
+ emptyPlaceholder: String = "— no data —"
|
|
|
+): ExportSheet<T> = ExportSheet(
|
|
|
+ sheetNameOverride,
|
|
|
+ this,
|
|
|
+ null,
|
|
|
+ valueMappers,
|
|
|
+ T::class,
|
|
|
+ skipIfEmpty,
|
|
|
+ emptyPlaceholder
|
|
|
+)
|
|
|
|
|
|
// ======================= 导出器(Kotlin 反射版) =======================
|
|
|
object ExcelExporter {
|
|
|
@@ -89,17 +102,36 @@ object ExcelExporter {
|
|
|
val sheetName = decideSheetName(sheetSpec)
|
|
|
val sheet = wb.createSheet(safeSheetName(sheetName))
|
|
|
|
|
|
- val kClass: KClass<out Any>? = data.firstOrNull()?.let { it::class }
|
|
|
+ // 即使 data 为空,也尝试用 modelClass 推导列
|
|
|
+ val kClass: KClass<out Any>? = sheetSpec.modelClass ?: data.firstOrNull()?.let { it::class }
|
|
|
|
|
|
- val (orderedProps, headers, widths, formatters) =
|
|
|
+ // 列定义(表头/宽度/格式器)
|
|
|
+ var (orderedProps, headers, widths, formatters) =
|
|
|
if (sheetSpec.customHeaders != null) {
|
|
|
pickByCustomHeaders(kClass, sheetSpec.customHeaders, styles.locale)
|
|
|
} else {
|
|
|
buildColumnsFromAnnotationsOrProps(kClass, styles.locale)
|
|
|
}
|
|
|
|
|
|
- // Header
|
|
|
- run {
|
|
|
+ // 如果确实拿不到任何列定义,但 data 又是空 → 根据策略处理
|
|
|
+ if (headers.isEmpty() && data.isEmpty()) {
|
|
|
+ if (sheetSpec.skipIfEmpty) {
|
|
|
+ // 完全跳过这个 sheet
|
|
|
+ // 注意:我们已经创建了 sheet,这里删除它再返回
|
|
|
+ val idx = wb.getSheetIndex(sheet)
|
|
|
+ wb.removeSheetAt(idx)
|
|
|
+ return
|
|
|
+ } else {
|
|
|
+ // 写一个占位列
|
|
|
+ headers = listOf(I18nManager.t("No columns"))
|
|
|
+ widths = listOf(-1)
|
|
|
+ formatters = listOf({ v: Any? -> v })
|
|
|
+ orderedProps = emptyList()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 写表头
|
|
|
+ if (headers.isNotEmpty()) {
|
|
|
val row = sheet.createRow(0)
|
|
|
headers.forEachIndexed { idx, title ->
|
|
|
val cell = row.createCell(idx)
|
|
|
@@ -108,40 +140,47 @@ object ExcelExporter {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Rows
|
|
|
- data.forEachIndexed { index, item ->
|
|
|
- val row = sheet.createRow(index + 1)
|
|
|
- orderedProps.forEachIndexed { c, p ->
|
|
|
- val cell = row.createCell(c)
|
|
|
- val raw = getPropertyValue(p, item)
|
|
|
- val mapped = sheetSpec.valueMappers[p.name]?.invoke(raw) ?: raw
|
|
|
- val formatted = formatters[c].invoke(mapped)
|
|
|
- setCellValueCompat(cell, formatted, styles)
|
|
|
+ // 写数据 or 占位
|
|
|
+ if (data.isNotEmpty()) {
|
|
|
+ data.forEachIndexed { index, item ->
|
|
|
+ val row = sheet.createRow(index + 1)
|
|
|
+ orderedProps.forEachIndexed { c, p ->
|
|
|
+ val cell = row.createCell(c)
|
|
|
+ val raw = getPropertyValue(p, item)
|
|
|
+ val mapped = sheetSpec.valueMappers[p.name]?.invoke(raw) ?: raw
|
|
|
+ val formatted = formatters[c].invoke(mapped)
|
|
|
+ setCellValueCompat(cell, formatted, styles)
|
|
|
+ }
|
|
|
}
|
|
|
+ } else if (!sheetSpec.skipIfEmpty) {
|
|
|
+ // 占位一行
|
|
|
+ val row = sheet.createRow(1)
|
|
|
+ val cell = row.createCell(0)
|
|
|
+ cell.setCellValue(I18nManager.t(sheetSpec.emptyPlaceholder))
|
|
|
+ cell.setCellStyle(styles.body)
|
|
|
}
|
|
|
|
|
|
- // Widths
|
|
|
+ // 列宽估算(仅当有列时)
|
|
|
val colCount = headers.size
|
|
|
- for (i in 0 until colCount) {
|
|
|
- val w = widths[i]
|
|
|
- if (w > 0) {
|
|
|
- sheet.setColumnWidth(i, w * 256)
|
|
|
- continue
|
|
|
- }
|
|
|
- if (USE_AUTO_SIZE) {
|
|
|
- // Android 下不建议开
|
|
|
- // sheet.autoSizeColumn(i)
|
|
|
- // sheet.setColumnWidth(i, max(sheet.getColumnWidth(i), 12 * 256))
|
|
|
- } else {
|
|
|
- val maxChars = estimateColumnChars(sheet, i, SCAN_ROWS_FOR_WIDTH)
|
|
|
- val finalChars = max(12, minOf(maxChars + 2, 60))
|
|
|
- sheet.setColumnWidth(i, finalChars * 256)
|
|
|
+ if (colCount > 0) {
|
|
|
+ for (i in 0 until colCount) {
|
|
|
+ val w = widths[i]
|
|
|
+ if (w > 0) {
|
|
|
+ sheet.setColumnWidth(i, w * 256)
|
|
|
+ } else if (!USE_AUTO_SIZE) {
|
|
|
+ val maxChars = estimateColumnChars(sheet, i, SCAN_ROWS_FOR_WIDTH)
|
|
|
+ val finalChars = max(12, minOf(maxChars + 2, 60))
|
|
|
+ sheet.setColumnWidth(i, finalChars * 256)
|
|
|
+ } else {
|
|
|
+ // Android 不推荐,但保留逻辑
|
|
|
+ // sheet.autoSizeColumn(i)
|
|
|
+ // sheet.setColumnWidth(i, max(sheet.getColumnWidth(i), 12 * 256))
|
|
|
+ }
|
|
|
}
|
|
|
+ // 冻结表头 & 筛选(仅当有表头列时)
|
|
|
+ sheet.createFreezePane(0, 1)
|
|
|
+ sheet.setAutoFilter(org.apache.poi.ss.util.CellRangeAddress(0, 0, 0, colCount - 1))
|
|
|
}
|
|
|
-
|
|
|
- // 冻结表头 & 筛选
|
|
|
- sheet.createFreezePane(0, 1)
|
|
|
- sheet.setAutoFilter(org.apache.poi.ss.util.CellRangeAddress(0, 0, 0, colCount - 1))
|
|
|
}
|
|
|
|
|
|
/** 扫描前 N 行单元格内容估算列宽(ASCII 算 1,中文等算 2) */
|
|
|
@@ -156,18 +195,14 @@ object ExcelExporter {
|
|
|
val row = sheet.getRow(r) ?: continue
|
|
|
val cell = row.getCell(col) ?: continue
|
|
|
val text = when (cell.cellType) {
|
|
|
- CellType.STRING.code -> cell.stringCellValue
|
|
|
+ CellType.STRING.code -> cell.stringCellValue
|
|
|
CellType.NUMERIC.code -> {
|
|
|
if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell))
|
|
|
cell.dateCellValue?.toString() ?: ""
|
|
|
else cell.numericCellValue.toString()
|
|
|
}
|
|
|
CellType.BOOLEAN.code -> cell.booleanCellValue.toString()
|
|
|
- CellType.FORMULA.code -> try {
|
|
|
- cell.stringCellValue
|
|
|
- } catch (_: Throwable) {
|
|
|
- cell.cellFormula
|
|
|
- }
|
|
|
+ CellType.FORMULA.code -> try { cell.stringCellValue } catch (_: Throwable) { cell.cellFormula }
|
|
|
else -> ""
|
|
|
}
|
|
|
val len = visualLength(text)
|
|
|
@@ -223,7 +258,12 @@ object ExcelExporter {
|
|
|
headersMap: LinkedHashMap<String, String>,
|
|
|
locale: Locale
|
|
|
): Quad<List<KProperty1<out Any, *>>, List<String>, List<Int>, List<(Any?) -> Any?>> {
|
|
|
- if (kClass == null) return Quad(emptyList(), emptyList(), emptyList(), emptyList())
|
|
|
+ if (kClass == null) {
|
|
|
+ val headers = headersMap.values.map { I18nManager.t(it) }
|
|
|
+ val widths = List(headers.size) { -1 }
|
|
|
+ val fmts = List(headers.size) { { v: Any? -> v } }
|
|
|
+ return Quad(emptyList(), headers, widths, fmts)
|
|
|
+ }
|
|
|
|
|
|
val all = getAllExportableProps(kClass)
|
|
|
val byName = all.associateBy { it.name }
|
|
|
@@ -234,17 +274,15 @@ object ExcelExporter {
|
|
|
val formatters = ArrayList<(Any?) -> Any?>(headersMap.size)
|
|
|
|
|
|
headersMap.forEach { (propName, headerText) ->
|
|
|
- val p = byName[propName]
|
|
|
- ?: error("Property '$propName' not found or not exportable.")
|
|
|
+ val p = byName[propName] ?: error("Property '$propName' not found or not exportable.")
|
|
|
selected += p
|
|
|
- headers += I18nManager.t(headerText)
|
|
|
+ headers += I18nManager.t(headerText)
|
|
|
|
|
|
val ann = p.findExcelColumn()
|
|
|
widths += (ann?.width ?: -1)
|
|
|
val pattern = ann?.datePattern ?: "yyyy-MM-dd HH:mm:ss"
|
|
|
formatters += buildFormatterForProp(p, pattern, locale)
|
|
|
}
|
|
|
-
|
|
|
return Quad(selected, headers, widths, formatters)
|
|
|
}
|
|
|
|
|
|
@@ -352,9 +390,9 @@ object ExcelExporter {
|
|
|
// ----------- 表名 & 注解读取 -----------
|
|
|
private fun decideSheetName(spec: ExportSheet<out Any>): String {
|
|
|
if (!spec.sheetName.isNullOrBlank()) return I18nManager.t(spec.sheetName)
|
|
|
- val first = spec.data.firstOrNull() ?: return I18nManager.t("Sheet1")
|
|
|
- val ann = first::class.findAnnotation<ExcelSheet>()
|
|
|
- val raw = ann?.name?.takeIf { it.isNotBlank() } ?: first::class.simpleName ?: "Sheet1"
|
|
|
+ val cls = spec.modelClass ?: spec.data.firstOrNull()?.let { it::class }
|
|
|
+ val raw = cls?.findAnnotation<ExcelSheet>()?.name?.takeIf { it.isNotBlank() }
|
|
|
+ ?: cls?.simpleName ?: "Sheet1"
|
|
|
return I18nManager.t(raw)
|
|
|
}
|
|
|
|