Browse Source

feat(excel): 添加Excel/SJS文件处理控制器并优化模板引擎功能

- 新增ExcelController用于处理pressure2数据并生成SJS文件
- 实现GcExcel模板数据绑定、图片处理和表格样式同步功能
- 添加续页处理逻辑支持自动生成和隐藏空页
- 优化GrapeCityController中的JSON数据解析和表格绑定逻辑
- 增强浮动图片和单元格图片的处理机制
- 添加DTO类和Lombok依赖支持数据传输对象
xuzhancheng 2 tuần trước cách đây
mục cha
commit
2d7fb8630a

+ 5 - 0
pom.xml

@@ -55,6 +55,11 @@
             <artifactId>fastjson2</artifactId>
             <version>2.0.57</version>
         </dependency>
+        <dependency>
+        <groupId>org.projectlombok</groupId>
+        <artifactId>lombok</artifactId>
+        <version>1.18.36</version>
+        </dependency>
     </dependencies>
 
     <build>

+ 631 - 0
src/main/java/com/grapecity/controller/ExcelController.java

@@ -0,0 +1,631 @@
+package com.grapecity.controller;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.grapecity.documents.excel.*;
+import com.grapecity.documents.excel.drawing.ImageType;
+import com.grapecity.documents.excel.template.DataSource.JsonDataSource;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+
+/**
+ * Excel / 葡萄城 SJS 文件 处理控制器
+ * 接收 pressure2 收集的完整数据,使用 GcExcel 处理后返回 SJS 文件
+ * 不依赖 pressure2 的任何服务,所有数据通过请求参数传入
+ */
+@RestController
+@Slf4j
+@RequestMapping("/excel")
+public class ExcelController {
+
+    @PostMapping("/process")
+    public void processExcel(@RequestBody Map<String, Object> request, HttpServletResponse response) {
+        doProcessExcel(request, true, response);
+    }
+
+    @PostMapping("/process-no-page")
+    public void processExcelNoPage(@RequestBody Map<String, Object> request, HttpServletResponse response) {
+        doProcessExcel(request, false, response);
+    }
+
+    /**
+     * 核心处理入口:从请求中提取数据,使用 GcExcel 处理工作簿,返回 SJS 文件
+     */
+    private void doProcessExcel(Map<String, Object> request, boolean defaultContinuePage, HttpServletResponse response) {
+        try {
+            byte[] templateBytes = convertToByteArray(request.get("templateBytes"), "templateBytes");
+            Map<String, Object> data = extractDataMap(request.get("data"));
+            Map<String, byte[]> fileBytes = convertFileBytesMap(request.get("fileBytes"));
+            List<String> editCols = extractStringList(request.get("editCols"));
+            List<String> imgCols = extractStringList(request.get("imgCols"));
+            Boolean continuePage = request.get("continuePage") != null
+                    ? Boolean.valueOf(request.get("continuePage").toString())
+                    : defaultContinuePage;
+            String copyConfigJson = request.get("copyConfigJson") != null
+                    ? request.get("copyConfigJson").toString()
+                    : null;
+            List<String> existingColCodes = extractStringList(request.get("existingColCodes"));
+
+            log.info("开始处理 Excel, continuePage={}", continuePage);
+
+            byte[] processedBytes = processWorkbook(templateBytes, data, fileBytes, imgCols, editCols);
+
+            List<String> newColCodes = new ArrayList<>();
+            if (Boolean.TRUE.equals(continuePage) && copyConfigJson != null) {
+                processedBytes = handleContinuationPages(processedBytes, copyConfigJson, existingColCodes, newColCodes);
+            }
+
+            String fileName = URLEncoder.encode("report.sjs", StandardCharsets.UTF_8);
+            response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
+            response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
+                    "attachment; filename*=UTF-8''" + fileName);
+            response.setContentLength(processedBytes.length);
+
+            if (!newColCodes.isEmpty()) {
+                response.setHeader("X-New-Col-Codes", JSON.toJSONString(newColCodes));
+            }
+
+            response.getOutputStream().write(processedBytes);
+            response.getOutputStream().flush();
+
+            log.info("Excel 处理完成, 文件大小={}KB", processedBytes.length / 1024);
+        } catch (Exception e) {
+            log.error("处理 Excel 失败", e);
+            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+            try {
+                response.getWriter().write("{\"error\":\"" + e.getMessage() + "\"}");
+            } catch (Exception ignored) {
+            }
+        }
+    }
+
+    /**
+     * 核心处理逻辑:使用 GcExcel 处理工作簿
+     * 包含:设置数据源、图片处理、可编辑字段高亮、表格样式同步
+     */
+    private byte[] processWorkbook(byte[] templateBytes, Map<String, Object> dataMap,
+                                   Map<String, byte[]> fileBytes,
+                                   List<String> imgCols, List<String> editCols) throws Exception {
+        WorkbookOptions workbookOptions = new WorkbookOptions();
+        workbookOptions.setPixelBasedColumnWidth(true);
+
+        Workbook workbook = new Workbook(workbookOptions);
+        workbook.open(new ByteArrayInputStream(templateBytes), OpenFileFormat.Sjs);
+
+        for (int i = 0; i < workbook.getWorksheets().getCount(); i++) {
+            IWorksheet worksheet = workbook.getWorksheets().get(i);
+
+            Map<ITable, List<int[]>> tableMergeInfo = collectTableMergeInfo(worksheet);
+
+            worksheet.setDataSource(new JsonDataSource(JSON.toJSONString(dataMap)));
+
+            copyTableRowStyles(worksheet, tableMergeInfo);
+
+            handleCellImages(worksheet, fileBytes);
+
+            handleImageCols(worksheet, imgCols, dataMap, fileBytes);
+
+            handleFloatingImages(worksheet, dataMap, fileBytes);
+
+            handleCheckboxCells(worksheet);
+
+            highlightEditableCells(worksheet, editCols);
+        }
+
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+        workbook.save(outputStream, SaveFileFormat.Sjs);
+        return outputStream.toByteArray();
+    }
+
+    /**
+     * 处理单元格中的图片:将以 .jpg/.png 结尾的值替换为背景图片
+     * 使用传入的 fileBytes 替代 fileApi 获取图片数据
+     */
+    private void handleCellImages(IWorksheet worksheet, Map<String, byte[]> fileBytes) {
+        IRange usedRange = worksheet.getUsedRange();
+        if (usedRange == null) return;
+
+        for (int row = 0; row < usedRange.getRowCount(); row++) {
+            for (int col = 0; col < usedRange.getColumnCount(); col++) {
+                IRange cell = worksheet.getRange(row, col);
+                if (cell.getBindingPath() == null || cell.getValue() == null) continue;
+
+                Object valueObj = cell.getValue();
+                String value = valueObj instanceof String ? (String) valueObj : String.valueOf(valueObj);
+
+                if (!value.endsWith(".png") && !value.endsWith(".jpg")) continue;
+
+                if (!value.contains(",")) {
+                    byte[] imgBytes = fileBytes.get(value);
+                    if (imgBytes != null) {
+                        cell.setValue(null);
+                        IRange mergeArea = cell.getMergeArea();
+                        mergeArea.setBackgroundImage(imgBytes);
+                    } else {
+                        log.warn("未找到图片数据: {}", value);
+                    }
+                } else {
+                    String[] split = value.split(",");
+                    byte[][] imgBytesArr = new byte[split.length][];
+                    boolean allSuccess = true;
+                    for (int k = 0; k < split.length; k++) {
+                        String trimmedPath = split[k].trim();
+                        imgBytesArr[k] = fileBytes.get(trimmedPath);
+                        if (imgBytesArr[k] == null) {
+                            log.warn("未找到图片数据: {}", trimmedPath);
+                            allSuccess = false;
+                            break;
+                        }
+                    }
+                    if (allSuccess && imgBytesArr.length > 0) {
+                        cell.setValue(null);
+                        IRange mergeArea = cell.getMergeArea();
+                        byte[] mergedImg = GrapeCityController.mergeImages(imgBytesArr);
+                        mergeArea.setBackgroundImage(mergedImg);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 处理图片字段类型 (colValType == 4):单元格中存储 JSON 数组 [{url, x, y, width, height}]
+     * 使用传入的 fileBytes 替代 fileApi 获取图片数据
+     */
+    private void handleImageCols(IWorksheet worksheet, List<String> imgCols, Map<String, Object> dataMap,
+                                 Map<String, byte[]> fileBytes) {
+        if (imgCols == null || imgCols.isEmpty()) return;
+        if (dataMap == null) return;
+
+        IRange usedRange = worksheet.getUsedRange();
+        if (usedRange == null) return;
+
+        for (int row = 0; row < usedRange.getRowCount(); row++) {
+            for (int col = 0; col < usedRange.getColumnCount(); col++) {
+                String bindingPath = worksheet.getRange(row, col).getBindingPath();
+                if (bindingPath == null || !imgCols.contains(bindingPath)) continue;
+
+                Object valueObj = dataMap.get(bindingPath);
+                if (valueObj == null) continue;
+
+                JSONArray imgArr = toJSONArray(valueObj);
+                if (imgArr == null || imgArr.isEmpty()) continue;
+
+                worksheet.getRange(row, col).setValue(null);
+
+                for (int k = 0; k < imgArr.size(); k++) {
+                    try {
+                        JSONObject imgItem = toJSONObject(imgArr.get(k));
+                        if (imgItem == null) continue;
+                        String url = imgItem.getString("url");
+                        if (url == null) continue;
+
+                        byte[] imgBytes = fileBytes.get(url);
+                        if (imgBytes == null) {
+                            log.warn("未找到图片字段数据: {}", url);
+                            continue;
+                        }
+                        ByteArrayInputStream imgStream = new ByteArrayInputStream(imgBytes);
+                        worksheet.getShapes().addPictureInPixel(
+                                JSON.toJSONString(imgItem),
+                                imgStream, ImageType.JPG,
+                                imgItem.getDoubleValue("x"),
+                                imgItem.getDoubleValue("y"),
+                                imgItem.getDoubleValue("width"),
+                                imgItem.getDoubleValue("height")
+                        );
+                    } catch (Exception e) {
+                        log.warn("添加图片形状失败: index={}", k, e);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 处理浮动图片:从数据源的 Illustration 字段解析并添加
+     * 使用传入的 fileBytes 替代 fileApi 获取图片数据
+     */
+    private void handleFloatingImages(IWorksheet worksheet, Map<String, Object> dataMap,
+                                      Map<String, byte[]> fileBytes) {
+        if (dataMap == null) return;
+
+        Object illustrationObj = dataMap.get("Illustration");
+        if (illustrationObj == null) return;
+
+        JSONArray jsonArray = toJSONArray(illustrationObj);
+        if (jsonArray == null || jsonArray.isEmpty()) return;
+
+        List<JSONObject> remainingItems = new ArrayList<>();
+        for (int k = 0; k < jsonArray.size(); k++) {
+            JSONObject jsonObject = toJSONObject(jsonArray.get(k));
+            if (jsonObject == null) continue;
+            String sheetName = jsonObject.getString("sheet");
+            if (worksheet.getName().equals(sheetName)) {
+                try {
+                    String url = jsonObject.getString("url");
+                    byte[] imgBytes = fileBytes.get(url);
+                    if (imgBytes == null) {
+                        log.warn("未找到浮动图片数据: {}", url);
+                        continue;
+                    }
+                    ByteArrayInputStream imgStream = new ByteArrayInputStream(imgBytes);
+                    worksheet.getShapes().addPictureInPixel(
+                            url,
+                            imgStream, ImageType.JPG,
+                            jsonObject.getDoubleValue("x"),
+                            jsonObject.getDoubleValue("y"),
+                            jsonObject.getDoubleValue("width"),
+                            jsonObject.getDoubleValue("height")
+                    );
+                } catch (Exception e) {
+                    log.warn("添加浮动图片失败: {}", e.getMessage());
+                }
+            } else {
+                remainingItems.add(jsonObject);
+            }
+        }
+
+        if (!remainingItems.isEmpty()) {
+            dataMap.put("Illustration", JSON.toJSONString(remainingItems));
+        } else {
+            dataMap.remove("Illustration");
+        }
+    }
+
+    /**
+     * 处理复选框单元格:清空非 "true" 的值
+     */
+    private void handleCheckboxCells(IWorksheet worksheet) {
+        IRange usedRange = worksheet.getUsedRange();
+        if (usedRange == null) return;
+
+        for (int row = 0; row < usedRange.getRowCount(); row++) {
+            for (int col = 0; col < usedRange.getColumnCount(); col++) {
+                IRange cell = worksheet.getRange(row, col);
+                if (cell.getCellType() instanceof CheckBoxCellType) {
+                    Object val = cell.getValue();
+                    if (val == null) continue;
+                    String strVal = val instanceof String ? (String) val : String.valueOf(val);
+                    if (!"true".equals(strVal)) {
+                        cell.setValue(null);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 高亮可编辑字段:全部锁定 → 可编辑字段解锁 + 设置 #FFF895 背景色
+     * 对齐前端 reportUtil.editReport 中的处理逻辑
+     */
+    private void highlightEditableCells(IWorksheet worksheet, List<String> editCols) {
+        if (editCols == null || editCols.isEmpty()) return;
+
+        IRange usedRange = worksheet.getUsedRange();
+        if (usedRange == null) return;
+        worksheet.protect();
+        // ① 全部单元格设为不可编辑(对齐前端 cellRange.allowEditInCell(false))
+        usedRange.setLocked(true);
+
+        // ② 遍历:解锁可编辑字段,同时收集地址
+        List<String> editableAddresses = new ArrayList<>();
+        for (int row = 0; row < usedRange.getRowCount(); row++) {
+            for (int col = 0; col < usedRange.getColumnCount(); col++) {
+                IRange cell = worksheet.getRange(row, col);
+                String bindingPath = cell.getBindingPath();
+                if (bindingPath != null && editCols.contains(bindingPath)) {
+                    // 解锁可编辑(对齐前端 sheet.getCell(i,j).allowEditInCell(true))
+                    cell.setLocked(false);
+                    editableAddresses.add(cell.getAddress());
+                }
+            }
+        }
+
+        // ③ 批量设置条件格式(对齐前端 cfs.addSpecificTextRule(greaterThanOrEqualsTo, null, style, arr))
+        if (!editableAddresses.isEmpty()) {
+            String unionAddress = String.join(",", editableAddresses);
+            IFormatCondition condition = (IFormatCondition) worksheet.getRange(unionAddress)
+                    .getFormatConditions().add(FormatConditionType.Expression, FormatConditionOperator.None, "=TRUE", null);
+            // #FFF895 = RGB(255, 248, 149)
+            condition.getInterior().setColor(Color.FromArgb(255, 255, 248, 149));
+            System.out.println(condition.getFormula1());
+        }
+    }
+
+    // ==================== 表格行样式同步 ====================
+
+    private Map<ITable, List<int[]>> collectTableMergeInfo(IWorksheet worksheet) {
+        Map<ITable, List<int[]>> result = new HashMap<>();
+        if (worksheet.getTables() == null) return result;
+
+        for (int t = 0; t < worksheet.getTables().getCount(); t++) {
+            ITable table = worksheet.getTables().get(t);
+            IRange tableRange = table.getRange();
+            int firstDataRow = tableRange.getRow() + 1;
+            int firstDataCol = tableRange.getColumn();
+            int colCount = tableRange.getColumnCount();
+
+            List<int[]> mergeRanges = new ArrayList<>();
+            try {
+                for (int c = firstDataCol; c < firstDataCol + colCount; c++) {
+                    IRange cell = worksheet.getRange(firstDataRow, c);
+                    IRange mergeArea = cell.getMergeArea();
+                    if (mergeArea != null && mergeArea.getColumnCount() > 1) {
+                        mergeRanges.add(new int[]{mergeArea.getColumn(), mergeArea.getColumnCount()});
+                        c = mergeArea.getColumn() + mergeArea.getColumnCount() - 1;
+                    }
+                }
+            } catch (Exception ignored) {
+            }
+            result.put(table, mergeRanges);
+        }
+        return result;
+    }
+
+    private void copyTableRowStyles(IWorksheet worksheet, Map<ITable, List<int[]>> tableMergeInfo) {
+        if (worksheet.getTables() == null) return;
+
+        for (int t = 0; t < worksheet.getTables().getCount(); t++) {
+            ITable table = worksheet.getTables().get(t);
+            IRange tableRange = table.getRange();
+            int rowCount = tableRange.getRowCount();
+            int colCount = tableRange.getColumnCount();
+            if (rowCount <= 1) continue;
+
+            int firstDataRow = tableRange.getRow();
+            int firstDataCol = tableRange.getColumn();
+
+            List<int[]> mergeColRanges = tableMergeInfo.getOrDefault(table, new ArrayList<>());
+
+            double firstRowHeight = worksheet.getRange(firstDataRow, firstDataCol).getRowHeight();
+            IRange sourceRow = worksheet.getRange(firstDataRow, firstDataCol, 1, colCount);
+
+            for (int r = 1; r < rowCount; r++) {
+                int targetRow = tableRange.getRow() + r;
+                IRange targetRowRange = worksheet.getRange(targetRow, firstDataCol, 1, colCount);
+                PasteOption pasteOption = new PasteOption();
+                pasteOption.setPasteType(EnumSet.of(PasteType.Formats));
+                sourceRow.copy(targetRowRange, pasteOption);
+                worksheet.getRange(targetRow, firstDataCol).setRowHeight(firstRowHeight);
+
+                for (int[] mergeRange : mergeColRanges) {
+                    try {
+                        worksheet.getRange(targetRow, mergeRange[0], 1, mergeRange[1]).merge();
+                    } catch (Exception ignored) {
+                    }
+                }
+            }
+
+            tableRange.setWrapText(true);
+        }
+    }
+
+    // ==================== 续页处理 ====================
+
+    /**
+     * 处理续页:检查 copyConfig 配置,处理隐藏空页和自动生成续页
+     * copyConfigJson 和 existingColCodes 由 pressure2 传入,newColCodes 通过参数返回给调用方
+     */
+    private byte[] handleContinuationPages(byte[] sjsBytes, String copyConfigJson,
+                                           List<String> existingColCodes, List<String> newColCodes) {
+        try {
+            JSONObject copyConfig = JSON.parseObject(copyConfigJson);
+
+            Boolean isContinuePage = copyConfig.getBoolean("isContinuePage");
+            if (!Boolean.TRUE.equals(isContinuePage)) return sjsBytes;
+
+            String sheetName = copyConfig.getString("sheetName");
+            if (sheetName == null) return sjsBytes;
+
+            JSONObject copyRange = copyConfig.getJSONObject("copyRange");
+            if (copyRange == null) return sjsBytes;
+
+            String[] topLeft = copyRange.getString("topLeft").split(",");
+            String[] topRight = copyRange.getString("topRight").split(",");
+            String[] bottomLeft = copyRange.getString("bottomLeft").split(",");
+
+            int col = Integer.parseInt(topLeft[0]);
+            int row = Integer.parseInt(topLeft[1]);
+            int colCount = Integer.parseInt(topRight[0]) - col;
+            int rowCount = Integer.parseInt(bottomLeft[1]) - row;
+
+            WorkbookOptions workbookOptions = new WorkbookOptions();
+            workbookOptions.setPixelBasedColumnWidth(true);
+            Workbook workbook = new Workbook(workbookOptions);
+            workbook.open(new ByteArrayInputStream(sjsBytes), OpenFileFormat.Sjs);
+
+            IWorksheet targetSheet = null;
+            for (int i = 0; i < workbook.getWorksheets().getCount(); i++) {
+                if (workbook.getWorksheets().get(i).getName().equals(sheetName)) {
+                    targetSheet = workbook.getWorksheets().get(i);
+                    break;
+                }
+            }
+            if (targetSheet == null) return sjsBytes;
+
+            Boolean hidden = copyConfig.getBoolean("hidden");
+            if (Boolean.TRUE.equals(hidden)) {
+                boolean isEmpty = true;
+                for (int i = 0; i <= colCount; i++) {
+                    Object cellValue = targetSheet.getRange(row, col + i).getValue();
+                    if (cellValue != null && !cellValue.toString().isEmpty()) {
+                        isEmpty = false;
+                        break;
+                    }
+                }
+                if (isEmpty) {
+                    targetSheet.setVisible(Visibility.Hidden);
+                }
+            }
+
+            List<String> effectiveExistingColCodes = existingColCodes != null ? existingColCodes : new ArrayList<>();
+
+            int sheetNum = 2;
+
+            boolean isNotEmpty = false;
+            for (int i = 0; i <= colCount; i++) {
+                Object cellValue = targetSheet.getRange(row + rowCount, col + i).getValue();
+                if (cellValue != null && !cellValue.toString().isEmpty()) {
+                    isNotEmpty = true;
+                    break;
+                }
+            }
+
+            if (isNotEmpty) {
+                generateContinuedSheet(workbook, targetSheet, sheetName, sheetNum,
+                        col, row, colCount, rowCount, effectiveExistingColCodes, newColCodes);
+                sheetNum++;
+            }
+
+            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+            workbook.save(outputStream, SaveFileFormat.Sjs);
+            return outputStream.toByteArray();
+        } catch (Exception e) {
+            log.warn("续页处理失败,回退返回原始 SJS", e);
+            return sjsBytes;
+        }
+    }
+
+    /**
+     * 生成续页工作表:复制 sheet 并调整绑定路径
+     * 新字段编码通过 newColCodes 参数返回给调用方
+     */
+    private void generateContinuedSheet(Workbook workbook, IWorksheet sourceSheet,
+                                        String sheetName, int sheetNum,
+                                        int col, int row, int colCount, int rowCount,
+                                        List<String> existingColCodes, List<String> newColCodes) {
+        String sheetJson = sourceSheet.toJson();
+        IWorksheet newSheet = workbook.getWorksheets().add();
+        newSheet.fromJson(sheetJson);
+        String newSheetName = sheetName + " (" + sheetNum + ")";
+        newSheet.setName(newSheetName);
+
+        for (int i = 0; i <= colCount; i++) {
+            for (int j = 0; j <= rowCount; j++) {
+                String bindingPath = sourceSheet.getRange(row + j, col + i).getBindingPath();
+                if (bindingPath != null && bindingPath.contains("_")) {
+                    String prefix = bindingPath.substring(0, bindingPath.lastIndexOf("_") + 1);
+                    String numStr = bindingPath.substring(bindingPath.lastIndexOf("_") + 1);
+                    try {
+                        int num = Integer.parseInt(numStr);
+                        int newNum = num + (rowCount + 1) * (sheetNum - 1);
+                        String newPath = prefix + newNum;
+                        newSheet.getRange(row + j, col + i).setBindingPath(newPath);
+                        if (!existingColCodes.contains(newPath) && !newColCodes.contains(newPath)) {
+                            newColCodes.add(newPath);
+                        }
+                    } catch (NumberFormatException ignored) {
+                    }
+                }
+            }
+        }
+    }
+
+    // ==================== 请求参数提取工具方法 ====================
+
+    @SuppressWarnings("unchecked")
+    private byte[] convertToByteArray(Object obj, String paramName) {
+        if (obj == null) {
+            throw new IllegalArgumentException(paramName + " 不能为空");
+        }
+        if (obj instanceof byte[]) {
+            return (byte[]) obj;
+        }
+        if (obj instanceof String str) {
+            return Base64.getDecoder().decode(str);
+        }
+        if (obj instanceof List) {
+            List<?> list = (List<?>) obj;
+            byte[] result = new byte[list.size()];
+            for (int i = 0; i < list.size(); i++) {
+                Object item = list.get(i);
+                if (item instanceof Number) {
+                    result[i] = ((Number) item).byteValue();
+                } else {
+                    throw new IllegalArgumentException(paramName + " 中包含非数字元素");
+                }
+            }
+            return result;
+        }
+        throw new IllegalArgumentException(paramName + " 必须是 byte[]/List<Number>/Base64String 类型");
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> extractDataMap(Object dataObj) {
+        if (dataObj == null) return new HashMap<>();
+        if (dataObj instanceof Map) return (Map<String, Object>) dataObj;
+        throw new IllegalArgumentException("data 必须是 Map 类型");
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, byte[]> convertFileBytesMap(Object fileBytesObj) {
+        if (fileBytesObj == null) return new HashMap<>();
+        if (!(fileBytesObj instanceof Map)) {
+            throw new IllegalArgumentException("fileBytes 必须是 Map 类型");
+        }
+        Map<?, ?> rawMap = (Map<?, ?>) fileBytesObj;
+        Map<String, byte[]> result = new HashMap<>(rawMap.size());
+        for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
+            String key = entry.getKey() != null ? entry.getKey().toString() : null;
+            if (key == null || key.isEmpty()) continue;
+            byte[] value = convertToByteArray(entry.getValue(), "fileBytes[" + key + "]");
+            result.put(key, value);
+        }
+        return result;
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<String> extractStringList(Object obj) {
+        if (obj == null) return new ArrayList<>();
+        if (obj instanceof List) return (List<String>) obj;
+        return new ArrayList<>();
+    }
+
+    // ==================== JSON 类型兼容工具方法 ====================
+
+    /**
+     * 将对象安全转换为 JSONArray
+     * 兼容 fastjson2 JSONArray 和 Jackson 反序列化的 ArrayList
+     */
+    private JSONArray toJSONArray(Object obj) {
+        if (obj instanceof JSONArray arr) return arr;
+        if (obj instanceof List<?> list) {
+            JSONArray arr = new JSONArray();
+            arr.addAll(list);
+            return arr;
+        }
+        if (obj instanceof String str && str.trim().startsWith("[")) {
+            try {
+                return JSON.parseArray(str);
+            } catch (Exception ignored) {
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 将对象安全转换为 JSONObject
+     * 兼容 fastjson2 JSONObject 和 Jackson 反序列化的 LinkedHashMap
+     */
+    private JSONObject toJSONObject(Object obj) {
+        if (obj instanceof JSONObject jo) return jo;
+        if (obj instanceof Map<?, ?> map) {
+            return new JSONObject(map);
+        }
+        return null;
+    }
+}

+ 132 - 33
src/main/java/com/grapecity/controller/GrapeCityController.java

@@ -166,7 +166,7 @@ public class GrapeCityController {
                 for (int k = 0; k < jsonArray.size(); k++) {
                     JSONObject jsonObject = jsonArray.getJSONObject(k);
                     if (jsonObject.getString("sheet").equals(worksheet.getName())) {
-                            imagePaths.add(jsonObject.getString("url"));
+                        imagePaths.add(jsonObject.getString("url"));
                     } else {
                         list.add(jsonObject);
                     }
@@ -194,7 +194,8 @@ public class GrapeCityController {
         // 转换 templateBytes
         byte[] templateBytes = convertToByteArray(request.get("templateBytes"), "templateBytes");
         Map<String, Object> data = extractDataMap(request.get("data"));
-
+        // 将 data 中的 JSON 字符串值解析为实际对象/数组,确保 GcExcel 模板引擎能正确处理表格绑定
+        normalizeDataForTemplate(data);
         // 转换 fileBytes
         Map<String, byte[]> fileBytes = convertFileBytesMap(request.get("fileBytes"));
 
@@ -221,7 +222,11 @@ public class GrapeCityController {
                 worksheet.getPageSetup().setRightMargin(7); // 右边距
                 worksheet.getPageSetup().setCenterHorizontally(true);
                 worksheet.getUsedRange().setShrinkToFit(true);
+                // 在 setDataSource 前收集表格第一行数据行的合并单元格规则(setDataSource 会将已有合并拆开)
+                Map<ITable, List<int[]>> tableMergeInfo = collectTableMergeInfo(worksheet);
                 worksheet.setDataSource(new JsonDataSource(JSON.toJSONString(data)));
+                // 数据源绑定后,将第一行数据行的样式、行高、合并规则复制到所有自动扩展的数据行
+                copyTableRowStyles(worksheet, tableMergeInfo);
                 if (!worksheet.getName().contains("封面") && !worksheet.getName().contains("注意")) {
                     worksheet.getPageSetup().setIsAutoFirstPageNumber(true);
                 }
@@ -324,43 +329,46 @@ public class GrapeCityController {
      * 处理浮动图片
      */
     private void processFloatingImages(IWorksheet worksheet, Map<String, Object> data, Map<String, byte[]> fileBytes) {
-        if (data.get("Illustration") == null) {
-            return;
-        }
-
         Object illustrationObj = data.get("Illustration");
-        String illustration = illustrationObj != null ? illustrationObj.toString() : null;
-
-        if (illustration != null && illustration.startsWith("[") && illustration.endsWith("]")) {
-            JSONArray jsonArray = JSON.parseArray(illustration);
-            List<JSONObject> list = new ArrayList<>();
-
-            for (int k = 0; k < jsonArray.size(); k++) {
-                JSONObject jsonObject = jsonArray.getJSONObject(k);
-                if (jsonObject.getString("sheet").equals(worksheet.getName())) {
-                    String url = jsonObject.getString("url");
-                    byte[] bytes = fileBytes.get(url);
-                    if (bytes != null) {
-                        try (InputStream byteArrayInputStream = new ByteArrayInputStream(bytes)) {
-                            worksheet.getShapes().addPictureInPixel(
-                                    byteArrayInputStream,
-                                    ImageType.JPG,
-                                    jsonObject.getDouble("x"),
-                                    jsonObject.getDouble("y"),
-                                    jsonObject.getDouble("width"),
-                                    jsonObject.getDouble("height")
-                            );
-                        } catch (IOException e) {
-                            logger.warn("添加浮动图片失败:{}", url, e);
+        if (illustrationObj != null) {
+            JSONArray jsonArray;
+            if (illustrationObj instanceof JSONArray arr) {
+                jsonArray = arr;
+            } else if (illustrationObj instanceof String str && str.trim().startsWith("[")) {
+                jsonArray = JSON.parseArray(str);
+            } else {
+                jsonArray = null;
+            }
+            if (jsonArray != null) {
+                List<JSONObject> list = new ArrayList<>();
+
+                for (int k = 0; k < jsonArray.size(); k++) {
+                    JSONObject jsonObject = jsonArray.getJSONObject(k);
+                    if (jsonObject.getString("sheet").equals(worksheet.getName())) {
+                        String url = jsonObject.getString("url");
+                        byte[] bytes = fileBytes.get(url);
+                        if (bytes != null) {
+                            try (InputStream byteArrayInputStream = new ByteArrayInputStream(bytes)) {
+                                worksheet.getShapes().addPictureInPixel(
+                                        byteArrayInputStream,
+                                        ImageType.JPG,
+                                        jsonObject.getDouble("x"),
+                                        jsonObject.getDouble("y"),
+                                        jsonObject.getDouble("width"),
+                                        jsonObject.getDouble("height")
+                                );
+                            } catch (IOException e) {
+                                logger.warn("添加浮动图片失败:{}", url, e);
+                            }
+                        } else {
+                            logger.warn("未找到浮动图片数据:{}", url);
                         }
                     } else {
-                        logger.warn("未找到浮动图片数据:{}", url);
+                        list.add(jsonObject);
                     }
-                } else {
-                    list.add(jsonObject);
                 }
+                data.put("Illustration", JSON.toJSONString(list));
             }
-            data.put("Illustration", JSON.toJSONString(list));
         }
     }
 
@@ -546,4 +554,95 @@ public class GrapeCityController {
 
         return result;
     }
+
+    /**
+     * 将 data Map 中 JSON 字符串类型的值解析为实际对象/数组
+     * 确保 GcExcel 模板引擎的表格绑定(如壁厚测定 table 列)能正确识别数组数据源
+     */
+    private void normalizeDataForTemplate(Map<String, Object> data) {
+        if (data == null || data.isEmpty()) return;
+        for (Map.Entry<String, Object> entry : data.entrySet()) {
+            Object value = entry.getValue();
+            if (value instanceof String str) {
+                String trimmed = str.trim();
+                if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
+                    try {
+                        Object parsed = JSON.parse(trimmed);
+                        entry.setValue(parsed);
+                    } catch (Exception e) {
+                        // 解析失败保持原字符串
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 在 setDataSource 之前收集所有表格第一行数据行的合并单元格规则
+     */
+    private Map<ITable, List<int[]>> collectTableMergeInfo(IWorksheet worksheet) {
+        Map<ITable, List<int[]>> result = new HashMap<>();
+        if (worksheet.getTables() == null) return result;
+        for (int t = 0; t < worksheet.getTables().getCount(); t++) {
+            ITable table = worksheet.getTables().get(t);
+            IRange tableRange = table.getRange();
+            int firstDataRow = tableRange.getRow() + 1;
+            int firstDataCol = tableRange.getColumn();
+            int colCount = tableRange.getColumnCount();
+
+            List<int[]> mergeRanges = new ArrayList<>();
+            try {
+                for (int c = firstDataCol; c < firstDataCol + colCount; c++) {
+                    IRange cell = worksheet.getRange(firstDataRow, c);
+                    IRange mergeArea = cell.getMergeArea();
+                    if (mergeArea != null && mergeArea.getColumnCount() > 1) {
+                        mergeRanges.add(new int[]{mergeArea.getColumn(), mergeArea.getColumnCount()});
+                        c = mergeArea.getColumn() + mergeArea.getColumnCount() - 1;
+                    }
+                }
+            } catch (Exception ignored) {
+            }
+            result.put(table, mergeRanges);
+        }
+        return result;
+    }
+
+    /**
+     * 将第一行数据行的样式、行高、合并单元格规则复制到表格所有自动扩展的数据行
+     */
+    private void copyTableRowStyles(IWorksheet worksheet, Map<ITable, List<int[]>> tableMergeInfo) {
+        if (worksheet.getTables() == null) return;
+        for (int t = 0; t < worksheet.getTables().getCount(); t++) {
+            ITable table = worksheet.getTables().get(t);
+            IRange tableRange = table.getRange();
+            int rowCount = tableRange.getRowCount();
+            int colCount = tableRange.getColumnCount();
+            if (rowCount <= 1) continue;
+
+            int firstDataRow = tableRange.getRow();
+            int firstDataCol = tableRange.getColumn();
+
+            // 从提前收集的数据中获取合并规则
+            List<int[]> mergeColRanges = tableMergeInfo.getOrDefault(table, new ArrayList<>());
+
+            double firstRowHeight = worksheet.getRange(firstDataRow, firstDataCol).getRowHeight();
+            IRange sourceRow = worksheet.getRange(firstDataRow, firstDataCol, 1, colCount);
+            for (int r = 1; r < rowCount; r++) {
+                int targetRow = tableRange.getRow() + r;
+                IRange targetRowRange = worksheet.getRange(targetRow, firstDataCol, 1, colCount);
+                PasteOption pasteOption = new PasteOption();
+                pasteOption.setPasteType(EnumSet.of(PasteType.Formats));
+                sourceRow.copy(targetRowRange, pasteOption);
+                worksheet.getRange(targetRow, firstDataCol).setRowHeight(firstRowHeight);
+                for (int[] mergeRange : mergeColRanges) {
+                    try {
+                        worksheet.getRange(targetRow, mergeRange[0], 1, mergeRange[1]).merge();
+                    } catch (Exception ignored) {
+                    }
+                }
+            }
+
+            tableRange.setWrapText(true);
+        }
+    }
 }

+ 9 - 0
src/main/java/com/grapecity/controller/dto/GrapeCityReqDTO.java

@@ -0,0 +1,9 @@
+package com.grapecity.controller.dto;
+
+import lombok.Data;
+
+@Data
+public class GrapeCityReqDTO {
+    private String templateId;
+    private String refId;
+}