xuzhancheng 2 týždňov pred
rodič
commit
449f18fc24

+ 343 - 0
tz-module-pressure2/tz-module-pressure2-biz/src/main/java/cn/start/tz/module/pressure2/controller/admin/excel/ExcelController.java

@@ -0,0 +1,343 @@
+package cn.start.tz.module.pressure2.controller.admin.excel;
+
+import cn.hutool.http.HttpResponse;
+import cn.hutool.http.HttpUtil;
+import cn.start.tz.framework.common.pojo.CommonResult;
+import cn.start.tz.module.infra.api.file.FileApi;
+import cn.start.tz.module.pressure2.controller.admin.dynamictb.vo.DynamicTbRespVO;
+import cn.start.tz.module.pressure2.controller.admin.dynamictbcol.vo.DynamicTbColRespVO;
+import cn.start.tz.module.pressure2.controller.admin.dynamictbval.vo.DynamicTBAndColVO;
+import cn.start.tz.module.pressure2.controller.admin.dynamictbval.vo.DynamicTBViewOrAddVO;
+import cn.start.tz.module.pressure2.controller.admin.dynamictbval.vo.DynamicTbValRespVO;
+import cn.start.tz.module.pressure2.controller.admin.excel.dto.GrapeCityReqDTO;
+import cn.start.tz.module.pressure2.service.dynamictb.DynamicTbService;
+import cn.start.tz.module.pressure2.service.dynamictbcol.DynamicTbColService;
+import cn.start.tz.module.pressure2.service.dynamictbval.DynamicTbValService;
+import cn.start.tz.module.pressure2.service.excel.ExcelService;
+import cn.start.tz.module.pressure2.service.standardfile.StandardTemplateService;
+import cn.start.tz.module.system.api.standard.StandardTemplateApi;
+import cn.start.tz.module.system.api.standard.dto.StandardTemplateRespDTO;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+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.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * Excel / 葡萄城文件 控制器
+ * pressure2 负责收集业务数据,通过 HTTP 调用 grape-city 服务处理 GcExcel 逻辑
+ * pressure2 没有葡萄城授权,所有 GcExcel 操作委托给 grape-city 服务
+ */
+@RestController
+@RequestMapping("/pressure2/excel")
+@Tag(name = "管理后台 - 葡萄城 Excel 处理")
+@Slf4j
+public class ExcelController {
+
+    @Value("${grapecity.url:null}")
+    private String grapecityUrl;
+
+    @Resource
+    private StandardTemplateApi standardTemplateApi;
+
+    @Resource
+    private StandardTemplateService standardTemplateService;
+
+    @Resource
+    private FileApi fileApi;
+
+    @Resource
+    private DynamicTbValService dynamicTbValService;
+
+    @Resource
+    private DynamicTbColService dynamicTbColService;
+
+    @Resource
+    private DynamicTbService dynamicTbService;
+
+    @Resource
+    private ExcelService excelService;
+
+    /**
+     * 原有接口(保留兼容)
+     * 调用葡萄城服务 /excel/process
+     */
+    @RequestMapping("/excel")
+    public void excel(@RequestBody GrapeCityReqDTO grapeCityReqDTO, HttpServletResponse response) {
+        callGrapeCityProcess(grapeCityReqDTO.getTemplateId(), grapeCityReqDTO.getRefId(), true, response);
+    }
+
+    /**
+     * 原有接口(保留兼容)
+     * 调用葡萄城服务 /excel/process-no-page
+     */
+    @RequestMapping("/excel-no-page")
+    public void excelNoPage(@RequestBody GrapeCityReqDTO grapeCityReqDTO, HttpServletResponse response) {
+        callGrapeCityProcess(grapeCityReqDTO.getTemplateId(), grapeCityReqDTO.getRefId(), false, response);
+    }
+
+    /**
+     * 处理并返回编辑好的葡萄城 SJS 文件(含续页)
+     *
+     */
+    @PostMapping("/process")
+    @Operation(summary = "后端处理葡萄城 Excel 文件(数据绑定、图片、可编辑字段、续页)")
+    public void processExcel(@RequestBody GrapeCityReqDTO reqDTO, HttpServletResponse response) throws Exception {
+        writeSjsResponse(excelService.processExcel(reqDTO.getTemplateId(), reqDTO.getRefId()), response);
+    }
+
+    /**
+     * 处理并返回编辑好的葡萄城 SJS 文件(不处理续页)
+     *
+     */
+    @PostMapping("/process-no-page")
+    @Operation(summary = "后端处理葡萄城 Excel 文件(不处理续页)")
+    public void processExcelNoPage(@RequestBody GrapeCityReqDTO reqDTO, HttpServletResponse response) throws Exception {
+        writeSjsResponse(excelService.processExcel(reqDTO.getTemplateId(), reqDTO.getRefId(), false), response);
+    }
+
+    /**
+     * 核心方法:收集 pressure2 的业务数据,调用 grape-city 服务处理 GcExcel 逻辑
+     * 流程:获取模板 → 获取动态数据 → 收集图片 → 构建请求 → 调用 grape-city → 返回 SJS
+     */
+    private void callGrapeCityProcess(String templateId, String refId, boolean continuePage,
+                                      HttpServletResponse response) {
+        try {
+            // 校验葡萄城服务地址
+            if (grapecityUrl == null || "null".equals(grapecityUrl) || grapecityUrl.isEmpty()) {
+                throw new IllegalStateException("葡萄城服务地址未配置(grapecity.url),无法处理 Excel");
+            }
+
+            log.info("开始收集 Excel 数据, templateId={}, refId={}, continuePage={}", templateId, refId, continuePage);
+
+            // 1. 获取模板 SJS 字节流
+//        CommonResult<StandardTemplateRespDTO> standardTemplate = standardTemplateApi.getStandardTemplate(templateId);
+//        String fileUrl = standardTemplate.getCheckedData().getFileUrl();
+            String fileUrl =  standardTemplateService.getById(templateId).getFileUrl();
+            byte[] templateBytes = fileApi.getFileByPath(fileUrl).getCheckedData();
+
+            // 2. 获取动态报表数据(实例 + 值列表 + 字段列表)
+            DynamicTBViewOrAddVO queryVO = new DynamicTBViewOrAddVO();
+            queryVO.setTemplateId(templateId);
+            queryVO.setRefId(refId);
+            DynamicTBAndColVO tbData = dynamicTbValService.getDynamicTbInsAndValByRefId(queryVO);
+
+            if (tbData == null || tbData.getDynamicTbInsRespVO() == null) {
+                log.warn("未找到动态报表实例数据, templateId={}, refId={}", templateId, refId);
+                writeSjsResponse(templateBytes, response);
+                return;
+            }
+
+            // 3. 构建数据 Map(将 JSON 字符串解析为实际对象,确保 GcExcel 模板引擎能正确处理)
+            Map<String, Object> dataMap = buildDataMap(tbData.getDynamicTbValRespVOList());
+
+            // 4. 获取可编辑字段列表
+            List<String> editCols = tbData.getDynamicTbColRespVOList().stream()
+                    .filter(i -> Boolean.TRUE.equals(i.getIsEdit()))
+                    .map(DynamicTbColRespVO::getColCode)
+                    .collect(Collectors.toList());
+
+            // 5. 获取图片字段列表(colValType == 4)
+            List<String> imgCols = tbData.getDynamicTbColRespVOList().stream()
+                    .filter(i -> i.getColValType() != null && i.getColValType() == 4)
+                    .map(DynamicTbColRespVO::getColCode)
+                    .collect(Collectors.toList());
+
+            // 6. 收集所有图片路径并下载图片字节
+            Map<String, byte[]> fileBytes = collectImageBytes(dataMap, imgCols);
+
+            // 7. 获取续页配置
+            String copyConfigJson = null;
+            List<String> existingColCodes = null;
+            if (continuePage) {
+                DynamicTbRespVO templateInfo = dynamicTbService.getDynamicTb(templateId);
+                Object rawConfig = templateInfo != null ? templateInfo.getCopyConfig() : null;
+                if (rawConfig != null) {
+                    // 兼容 fastjson1 JSONObject → JSON 字符串
+                    copyConfigJson = rawConfig instanceof JSONObject
+                            ? ((JSONObject) rawConfig).toJSONString()
+                            : rawConfig.toString();
+                }
+                existingColCodes = tbData.getDynamicTbColRespVOList().stream()
+                        .map(DynamicTbColRespVO::getColCode)
+                        .collect(Collectors.toList());
+            }
+
+            // 8. 构建请求参数,发送到 grape-city 服务
+            Map<String, Object> requestMap = new HashMap<>();
+            requestMap.put("templateBytes", templateBytes);
+            requestMap.put("data", dataMap);
+            requestMap.put("fileBytes", fileBytes);
+            requestMap.put("editCols", editCols);
+            requestMap.put("imgCols", imgCols);
+            requestMap.put("continuePage", continuePage);
+            if (copyConfigJson != null) {
+                requestMap.put("copyConfigJson", copyConfigJson);
+            }
+            if (existingColCodes != null) {
+                requestMap.put("existingColCodes", existingColCodes);
+            }
+
+            String endpoint = continuePage ? "/excel/process" : "/excel/process-no-page";
+            String jsonString = JSON.toJSONString(requestMap);
+
+            log.info("调用葡萄城服务: {}{}, 数据大小={}KB", grapecityUrl, endpoint, jsonString.length() / 1024);
+
+            HttpResponse httpResponse = HttpUtil.createPost(grapecityUrl + endpoint)
+                    .body(jsonString)
+                    .contentType("application/json")
+                    .execute();
+
+            // 9. 读取响应
+            byte[] sjsBytes = httpResponse.bodyBytes();
+
+            // 10. 处理续页新字段(grape-city 通过响应头返回新字段编码)
+            String newColCodesHeader = httpResponse.header("X-New-Col-Codes");
+            if (newColCodesHeader != null && !newColCodesHeader.isEmpty()) {
+                try {
+                    List<String> newColCodes = JSON.parseArray(newColCodesHeader, String.class);
+                    if (!newColCodes.isEmpty()) {
+                        dynamicTbColService.createDynamicTbColByCodes(newColCodes, templateId);
+                    }
+                } catch (Exception e) {
+                    log.warn("创建续页字段失败: {}", e.getMessage());
+                }
+            }
+
+            // 11. 返回 SJS 文件
+            writeSjsResponse(sjsBytes, response);
+
+            log.info("Excel 处理完成, templateId={}, refId={}, 文件大小={}KB", templateId, refId, sjsBytes.length / 1024);
+        } catch (Exception e) {
+            log.error("处理 Excel 失败, templateId={}, refId={}", templateId, refId, e);
+            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+            try {
+                response.getWriter().write("{\"error\":\"" + e.getMessage() + "\"}");
+            } catch (Exception ignored) {
+            }
+        }
+    }
+
+    /**
+     * 构建数据 Map,将 JSON 字符串解析为实际对象
+     * 确保 GcExcel 模板引擎能正确处理表格绑定
+     */
+    private Map<String, Object> buildDataMap(List<DynamicTbValRespVO> valRespVOList) {
+        Map<String, Object> dataMap = new HashMap<>();
+        if (valRespVOList == null) return dataMap;
+
+        for (DynamicTbValRespVO item : valRespVOList) {
+            String colCode = item.getColCode();
+            String valValue = item.getValValue();
+            if (colCode == null || valValue == null) continue;
+
+            String trimmed = valValue.trim();
+            if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
+                try {
+                    dataMap.put(colCode, JSON.parse(trimmed));
+                } catch (Exception e) {
+                    dataMap.put(colCode, valValue);
+                }
+            } else {
+                dataMap.put(colCode, valValue);
+            }
+        }
+        return dataMap;
+    }
+
+    /**
+     * 收集所有图片路径并下载图片字节
+     * 三种图片来源:
+     * 1. 数据值中以 .png/.jpg 结尾的字符串(单元格背景图)
+     * 2. 图片字段(imgCols)中的 JSON 数组 [{url, x, y, width, height}]
+     * 3. Illustration 浮动图片字段中的 JSON 数组 [{sheet, url, x, y, width, height}]
+     */
+    private Map<String, byte[]> collectImageBytes(Map<String, Object> dataMap, List<String> imgCols) {
+        Map<String, byte[]> fileBytes = new HashMap<>();
+        Set<String> imagePaths = new HashSet<>();
+
+        // 扫描所有数据值,收集图片路径
+        for (Map.Entry<String, Object> entry : dataMap.entrySet()) {
+            collectImagePathsFromValue(entry.getValue(), imagePaths,
+                    imgCols != null && imgCols.contains(entry.getKey()));
+        }
+
+        // 下载所有图片
+        for (String path : imagePaths) {
+            try {
+                byte[] data = fileApi.getFileByPath(path).getCheckedData();
+                fileBytes.put(path, data);
+            } catch (Exception e) {
+                log.warn("获取图片失败: path={}", path);
+            }
+        }
+
+        return fileBytes;
+    }
+
+    /**
+     * 从数据值中递归收集图片路径
+     *
+     * @param value    数据值(可能是 String、JSONArray、JSONObject 等)
+     * @param paths    收集到的图片路径集合
+     * @param isImgCol 是否是图片字段(colValType == 4)
+     */
+    private void collectImagePathsFromValue(Object value, Set<String> paths, boolean isImgCol) {
+        if (value == null) return;
+
+        if (value instanceof String str) {
+            // 单元格背景图片:以 .png/.jpg 结尾的路径
+            if (str.endsWith(".png") || str.endsWith(".jpg")) {
+                if (str.contains(",")) {
+                    for (String path : str.split(",")) {
+                        String trimmed = path.trim();
+                        if (!trimmed.isEmpty()) {
+                            paths.add(trimmed);
+                        }
+                    }
+                } else {
+                    paths.add(str);
+                }
+            }
+        } else if (value instanceof JSONArray arr) {
+            // 图片字段或 Illustration 中的 JSON 数组
+            for (int i = 0; i < arr.size(); i++) {
+                Object item = arr.get(i);
+                if (item instanceof JSONObject obj) {
+                    String url = obj.getString("url");
+                    if (url != null && !url.isEmpty()) {
+                        paths.add(url);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 将 SJS 字节流写入 HTTP 响应
+     */
+    private void writeSjsResponse(byte[] sjsBytes, HttpServletResponse response) throws Exception {
+        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(sjsBytes.length);
+        response.getOutputStream().write(sjsBytes);
+        response.getOutputStream().flush();
+    }
+}

+ 9 - 0
tz-module-pressure2/tz-module-pressure2-biz/src/main/java/cn/start/tz/module/pressure2/controller/admin/excel/dto/GrapeCityReqDTO.java

@@ -0,0 +1,9 @@
+package cn.start.tz.module.pressure2.controller.admin.excel.dto;
+
+import lombok.Data;
+
+@Data
+public class GrapeCityReqDTO {
+    private String templateId;
+    private String refId;
+}

+ 32 - 0
tz-module-pressure2/tz-module-pressure2-biz/src/main/java/cn/start/tz/module/pressure2/service/excel/ExcelService.java

@@ -0,0 +1,32 @@
+package cn.start.tz.module.pressure2.service.excel;
+
+/**
+ * Excel 处理 Service 接口
+ * 将前端 SpreadJS 编辑 Excel 的功能迁移到后端 GcExcel 处理
+ *
+ * @author system
+ */
+public interface ExcelService {
+
+    /**
+     * 处理并返回编辑好的 SJS 文件字节流
+     * 包含:设置数据源、图片处理、可编辑字段、表格处理、续页处理
+     *
+     * @param templateId 模板ID
+     * @param refId      关联ID
+     * @return SJS 文件字节流
+     * @throws Exception 处理异常
+     */
+    byte[] processExcel(String templateId, String refId) throws Exception;
+
+    /**
+     * 处理并返回编辑好的 SJS 文件字节流,指定是否生成续页
+     *
+     * @param templateId   模板ID
+     * @param refId        关联ID
+     * @param continuePage 是否处理续页
+     * @return SJS 文件字节流
+     * @throws Exception 处理异常
+     */
+    byte[] processExcel(String templateId, String refId, boolean continuePage) throws Exception;
+}

+ 634 - 0
tz-module-pressure2/tz-module-pressure2-biz/src/main/java/cn/start/tz/module/pressure2/service/excel/ExcelServiceImpl.java

@@ -0,0 +1,634 @@
+package cn.start.tz.module.pressure2.service.excel;
+
+import cn.start.tz.framework.common.pojo.CommonResult;
+import cn.start.tz.module.infra.api.file.FileApi;
+import cn.start.tz.module.pressure2.controller.admin.dynamictb.vo.DynamicTbRespVO;
+import cn.start.tz.module.pressure2.controller.admin.dynamictbcol.vo.DynamicTbColRespVO;
+import cn.start.tz.module.pressure2.controller.admin.dynamictbval.vo.DynamicTBAndColVO;
+import cn.start.tz.module.pressure2.controller.admin.dynamictbval.vo.DynamicTBViewOrAddVO;
+import cn.start.tz.module.pressure2.controller.admin.dynamictbval.vo.DynamicTbValRespVO;
+import cn.start.tz.module.pressure2.service.dynamictb.DynamicTbService;
+import cn.start.tz.module.pressure2.service.dynamictbcol.DynamicTbColService;
+import cn.start.tz.module.pressure2.service.dynamictbval.DynamicTbValService;
+import cn.start.tz.module.pressure2.service.standardfile.StandardTemplateService;
+import cn.start.tz.module.pressure2.util.ImageUtil;
+import cn.start.tz.module.system.api.standard.StandardTemplateApi;
+import cn.start.tz.module.system.api.standard.dto.StandardTemplateRespDTO;
+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.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * Excel 处理 Service 实现
+ * 将前端 SpreadJS 编辑 Excel 的功能迁移到后端 GcExcel 处理
+ * 参考 PdfServiceImpl 中的 GcExcel 使用模式
+ */
+@Service
+@Slf4j
+public class ExcelServiceImpl implements ExcelService {
+
+    @Resource
+    private StandardTemplateApi standardTemplateApi;
+
+    @Resource
+    private StandardTemplateService standardTemplateService;
+
+    @Resource
+    private DynamicTbValService dynamicTbValService;
+
+    @Resource
+    private DynamicTbColService dynamicTbColService;
+
+    @Resource
+    private DynamicTbService dynamicTbService;
+
+    @Resource
+    private FileApi fileApi;
+
+    @Override
+    public byte[] processExcel(String templateId, String refId) throws Exception {
+        return processExcel(templateId, refId, true);
+    }
+
+    @Override
+    public byte[] processExcel(String templateId, String refId, boolean continuePage) throws Exception {
+        // 1. 获取模板 SJS 字节流
+//        CommonResult<StandardTemplateRespDTO> standardTemplate = standardTemplateApi.getStandardTemplate(templateId);
+//        String fileUrl = standardTemplate.getCheckedData().getFileUrl();
+        String fileUrl = standardTemplateService.getById(templateId).getFileUrl();
+        byte[] templateBytes = fileApi.getFileByPath(fileUrl).getCheckedData();
+
+        // 2. 获取动态报表数据(实例 + 值列表 + 字段列表)
+        DynamicTBViewOrAddVO queryVO = new DynamicTBViewOrAddVO();
+        queryVO.setTemplateId(templateId);
+        queryVO.setRefId(refId);
+        DynamicTBAndColVO tbData = dynamicTbValService.getDynamicTbInsAndValByRefId(queryVO);
+
+        if (tbData == null || tbData.getDynamicTbInsRespVO() == null) {
+            log.warn("未找到动态报表实例数据, templateId={}, refId={}", templateId, refId);
+            return templateBytes;
+        }
+
+        // 3. 构建数据 Map
+        Map<String, Object> dataMap = buildDataMap(tbData.getDynamicTbValRespVOList());
+
+        // 4. 获取可编辑字段列表
+        List<String> editCols = tbData.getDynamicTbColRespVOList().stream()
+                .filter(i -> Boolean.TRUE.equals(i.getIsEdit()))
+                .map(DynamicTbColRespVO::getColCode)
+                .collect(Collectors.toList());
+
+        // 5. 获取图片字段列表
+        List<String> imgCols = tbData.getDynamicTbColRespVOList().stream()
+                .filter(i -> i.getColValType() != null && i.getColValType() == 4)
+                .map(DynamicTbColRespVO::getColCode)
+                .collect(Collectors.toList());
+
+        // 6. 使用 GcExcel 处理工作簿
+        byte[] processedBytes = processWorkbook(templateBytes, dataMap, imgCols, editCols);
+
+        // 7. 处理续页
+        if (continuePage) {
+            processedBytes = handleContinuationPages(processedBytes, templateId, tbData);
+        }
+
+        return processedBytes;
+    }
+
+    /**
+     * 构建数据 Map,将 JSON 字符串解析为实际对象
+     * 确保 GcExcel 模板引擎能正确处理表格绑定
+     */
+    private Map<String, Object> buildDataMap(List<DynamicTbValRespVO> valRespVOList) {
+        Map<String, Object> dataMap = new HashMap<>();
+        if (valRespVOList == null) return dataMap;
+
+        for (DynamicTbValRespVO item : valRespVOList) {
+            String colCode = item.getColCode();
+            String valValue = item.getValValue();
+            if (colCode == null || valValue == null) continue;
+
+            String trimmed = valValue.trim();
+            if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
+                try {
+                    dataMap.put(colCode, JSON.parse(trimmed));
+                } catch (Exception e) {
+                    dataMap.put(colCode, valValue);
+                }
+            } else {
+                dataMap.put(colCode, valValue);
+            }
+        }
+        return dataMap;
+    }
+
+    /**
+     * 核心处理逻辑:使用 GcExcel 处理工作簿
+     */
+    private byte[] processWorkbook(byte[] templateBytes, Map<String, Object> dataMap,
+                                   List<String> imgCols, List<String> editCols) throws Exception {
+        WorkbookOptions workbookOptions = new WorkbookOptions();
+        workbookOptions.setPixelBasedColumnWidth(true);
+
+        Workbook workbook = new Workbook(workbookOptions);
+        // SJS 格式不使用 XlsxOpenOptions,直接以 SJS 方式打开
+        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);
+
+            // -- 处理图片字段 (colValType == 4) --
+            handleImageCols(worksheet, imgCols, dataMap);
+
+            // -- 处理浮动图片 --
+            handleFloatingImages(worksheet, dataMap);
+
+            // -- 处理复选框 --
+            handleCheckboxCells(worksheet);
+
+            // -- 设置可编辑字段高亮 --
+            highlightEditableCells(worksheet, editCols);
+        }
+
+        // 保存为 SJS 格式返回
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+        workbook.save(outputStream, SaveFileFormat.Sjs);
+        return outputStream.toByteArray();
+    }
+
+    /**
+     * 处理单元格中的图片:将以 .jpg/.png 结尾的值替换为图片
+     * 参考 PdfServiceImpl.fullPdf() 中的图片处理逻辑
+     */
+    private void handleCellImages(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.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(",")) {
+                    try {
+                        byte[] imgBytes = fileApi.getFileByPath(value).getCheckedData();
+                        cell.setValue(null);
+                        IRange mergeArea = cell.getMergeArea();
+                        mergeArea.setBackgroundImage(imgBytes);
+                    } catch (Exception e) {
+                        log.warn("设置单元格背景图片失败: path={}, error={}", value, e.getMessage());
+                    }
+                } else {
+                    // 多张图片,横向合并
+                    String[] split = value.split(",");
+                    byte[][] imgBytesArr = new byte[split.length][];
+                    boolean allSuccess = true;
+                    for (int k = 0; k < split.length; k++) {
+                        try {
+                            imgBytesArr[k] = fileApi.getFileByPath(split[k]).getCheckedData();
+                        } catch (Exception e) {
+                            log.warn("获取图片失败: path={}", split[k]);
+                            allSuccess = false;
+                            break;
+                        }
+                    }
+                    if (allSuccess && imgBytesArr.length > 0) {
+                        cell.setValue(null);
+                        IRange mergeArea = cell.getMergeArea();
+                        byte[] mergedImg = ImageUtil.mergeImages(imgBytesArr);
+                        mergeArea.setBackgroundImage(mergedImg);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 处理图片字段类型 (colValType == 4):单元格中存储 JSON 数组 [{url, x, y, width, height}]
+     * 参考前端 editReport 中 imgCol 分支的处理
+     */
+    private void handleImageCols(IWorksheet worksheet, List<String> imgCols, Map<String, Object> dataMap) {
+        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 = imgArr.getJSONObject(k);
+                        String url = imgItem.getString("url");
+                        if (url == null) continue;
+
+                        byte[] imgBytes = fileApi.getFileByPath(url).getCheckedData();
+                        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 字段解析并添加
+     * 参考前端 editReport 中的浮动图片处理 + PdfServiceImpl.fullPdf() 中的 illustration 处理
+     */
+    private void handleFloatingImages(IWorksheet worksheet, Map<String, Object> dataMap) {
+        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 = jsonArray.getJSONObject(k);
+            String sheetName = jsonObject.getString("sheet");
+            if (worksheet.getName().equals(sheetName)) {
+                try {
+                    String url = jsonObject.getString("url");
+                    byte[] imgBytes = fileApi.getFileByPath(url).getCheckedData();
+                    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);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 对齐前端 reportUtil.editReport 中的可编辑字段处理:
+     * ① 全部单元格设为不可编辑
+     * ② 可编辑字段解锁
+     * ③ 用一条条件格式规则批量设置 #FFF895 黄色背景
+     * 打印 PDF 时可批量移除规则,不对最终输出产生影响
+     */
+    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());
+        }
+    }
+
+    // ==================== 表格行样式同步 ====================
+
+    /**
+     * 在 setDataSource 前收集所有表格第一行数据行的合并单元格规则
+     * 参考 PdfServiceImpl.collectTableMergeInfo()
+     */
+    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;
+    }
+
+    /**
+     * 将第一行数据行的样式、行高、合并规则复制到所有自动扩展的数据行
+     * 参考 PdfServiceImpl.copyTableRowStyles()
+     */
+    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 配置,处理隐藏空页和自动生成续页
+     * 参考前端 SpreadViewer.vue 中的 hiddenPage() 和 handleGenerate()
+     */
+    private byte[] handleContinuationPages(byte[] sjsBytes, String templateId, DynamicTBAndColVO tbData) {
+        try {
+            // 获取模板的 copyConfig(注意:DynamicTbRespVO 中的 copyConfig 是 fastjson1 JSONObject)
+            DynamicTbRespVO templateInfo = dynamicTbService.getDynamicTb(templateId);
+            Object rawConfig = templateInfo != null ? templateInfo.getCopyConfig() : null;
+            if (rawConfig == null) return sjsBytes;
+
+            // 兼容 fastjson1 JSONObject → fastjson2 JSONObject,通过序列化中转
+            JSONObject copyConfig = rawConfig instanceof JSONObject
+                    ? (JSONObject) rawConfig
+                    : JSON.parseObject(rawConfig.toString());
+
+            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> existingColCodes = tbData.getDynamicTbColRespVOList().stream()
+                    .map(DynamicTbColRespVO::getColCode)
+                    .collect(Collectors.toList());
+            List<String> newColCodes = 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) {
+                // 生成续页:复制 sheet 并调整绑定路径
+                generateContinuedSheet(workbook, targetSheet, sheetName, sheetNum,
+                        col, row, colCount, rowCount, existingColCodes, newColCodes);
+                sheetNum++;
+            }
+
+            // 如果有新字段编码,批量创建
+            if (!newColCodes.isEmpty()) {
+                try {
+                    dynamicTbColService.createDynamicTbColByCodes(newColCodes, templateId);
+                } catch (Exception e) {
+                    log.warn("批量创建续页字段失败: {}", e.getMessage());
+                }
+            }
+
+            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+            workbook.save(outputStream, SaveFileFormat.Sjs);
+            return outputStream.toByteArray();
+        } catch (Exception e) {
+            // 续页处理失败不影响主流程,返回原始 SJS
+            log.warn("续页处理失败,回退返回原始 SJS: templateId={}", templateId, e);
+            return sjsBytes;
+        }
+    }
+
+    /**
+     * 生成续页工作表
+     * 参考前端 handleGenerate() 中的逻辑
+     * 使用 GcExcel 的 toJson/fromJson 实现工作簿内 sheet 复制
+     */
+    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) {
+                    }
+                }
+            }
+        }
+    }
+
+    // ==================== 工具方法 ====================
+
+    /**
+     * 将对象安全转换为 JSONArray
+     */
+    private JSONArray toJSONArray(Object obj) {
+        if (obj instanceof JSONArray arr) return arr;
+        if (obj instanceof String str && str.trim().startsWith("[")) {
+            try {
+                return JSON.parseArray(str);
+            } catch (Exception ignored) {
+            }
+        }
+        return null;
+    }
+}

+ 115 - 6
tz-module-pressure2/tz-module-pressure2-biz/src/main/java/cn/start/tz/module/pressure2/service/pdf/PdfServiceImpl.java

@@ -11,9 +11,11 @@ import cn.start.tz.module.pressure2.controller.admin.dynamictbcol.vo.DynamicTbIm
 import cn.start.tz.module.pressure2.dal.dataobject.dynamictbcol.DynamicTbColDO;
 import cn.start.tz.module.pressure2.dal.dataobject.dynamictbins.DynamicTbInsDO;
 import cn.start.tz.module.pressure2.dal.dataobject.dynamictbval.DynamicTbValDO;
+import cn.start.tz.module.pressure2.dal.dataobject.standardfile.StandardTemplateDO;
 import cn.start.tz.module.pressure2.service.dynamictbcol.DynamicTbColService;
 import cn.start.tz.module.pressure2.service.dynamictbins.DynamicTbInsService;
 import cn.start.tz.module.pressure2.service.dynamictbval.DynamicTbValService;
+import cn.start.tz.module.pressure2.service.standardfile.StandardTemplateService;
 import cn.start.tz.module.pressure2.util.ImageUtil;
 import cn.start.tz.module.pressure2.util.WordToPdfUtils;
 import cn.start.tz.module.system.api.standard.StandardTemplateApi;
@@ -44,6 +46,8 @@ public class PdfServiceImpl implements PdfService {
 
     @Resource
     private StandardTemplateApi standardTemplateApi;
+    @Resource
+    private StandardTemplateService standardTemplateService;
 
     @Resource
     private DynamicTbValService dynamicTbValService;
@@ -105,8 +109,9 @@ public class PdfServiceImpl implements PdfService {
 
     @Override
     public byte[] pdf(String templateId, String instanceId) throws Exception {
-            CommonResult<StandardTemplateRespDTO> standardTemplate = standardTemplateApi.getStandardTemplate(templateId);
-            String fileUrl = standardTemplate.getCheckedData().getFileUrl();
+//            CommonResult<StandardTemplateRespDTO> standardTemplate = standardTemplateApi.getStandardTemplate(templateId);
+//            String fileUrl = standardTemplate.getCheckedData().getFileUrl();
+            String fileUrl =  standardTemplateService.getById(templateId).getFileUrl();
             byte[] bytes = null;
             bytes = fileApi.getFileByPath(fileUrl).getCheckedData();
             GrapeCityReqDTO grapeCityReqDTO = new GrapeCityReqDTO();
@@ -124,6 +129,8 @@ public class PdfServiceImpl implements PdfService {
 
     @Override
     public byte[] fullPdf(byte[] templateBytes, Map<String, Object> data, List<String> imgIns) throws Exception {
+        // 将 data 中的 JSON 字符串值解析为实际对象/数组,确保 GcExcel 模板引擎能正确处理表格绑定
+        normalizeDataForTemplate(data);
         if (grapecityUrl != null && !"null".equals(grapecityUrl)) {
             // 构建 filePath 接口的请求参数
             Map<String, Object> filePathRequest = new HashMap<>();
@@ -182,7 +189,11 @@ public class PdfServiceImpl implements PdfService {
             worksheet.getPageSetup().setLeftMargin(7); // 左边距
             worksheet.getPageSetup().setRightMargin(7); // 右边距
             worksheet.getPageSetup().setCenterHorizontally(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);
             }
@@ -225,10 +236,17 @@ public class PdfServiceImpl implements PdfService {
                 }
             }
             // 浮动图片
-            if (data.get("Illustration") != null) {
-                String illustration = (String) data.get("Illustration");
-                if (illustration.startsWith("[") && illustration.endsWith("]")) {
-                    JSONArray jsonArray = JSON.parseArray(illustration);
+            Object illustrationObj = data.get("Illustration");
+            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);
@@ -265,4 +283,95 @@ public class PdfServiceImpl implements PdfService {
         printManager.savePageInfosToPDF(byteArrayOutputStream, pages, pdfOptions);
         return byteArrayOutputStream.toByteArray();
     }
+
+    /**
+     * 将 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);
+        }
+    }
 }