|  | @@ -432,6 +432,13 @@ public class WbsTreeContractController extends BladeController {
 | 
	
		
			
				|  |  |              }
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  | +    // 1. 工具方法:统一主表名匹配格式(保留原逻辑,确保无字符差异)
 | 
	
		
			
				|  |  | +    private String getMatchableMainName(String name) {
 | 
	
		
			
				|  |  | +        if (name == null) return "";
 | 
	
		
			
				|  |  | +        String cleanName = name.replaceAll("\\s+", ""); // 去空格
 | 
	
		
			
				|  |  | +        cleanName = cleanName.replaceAll("[\\\\/:*?\"<>|_-]", ""); // 去特殊字符
 | 
	
		
			
				|  |  | +        return cleanName.toLowerCase(); // 转小写
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  |      @SneakyThrows
 | 
	
		
			
				|  |  |      @GetMapping("/download-node-excel")
 | 
	
		
			
				|  |  |      @ApiOperationSupport(order = 14)
 | 
	
	
		
			
				|  | @@ -444,45 +451,96 @@ public class WbsTreeContractController extends BladeController {
 | 
	
		
			
				|  |  |          if (ObjectUtil.isEmpty(formList)) {
 | 
	
		
			
				|  |  |              throw new ServiceException("该节点下没有找到对应的表单数据");
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  | -        // 对formList进行排序:主表优先,复制表按数字顺序排列
 | 
	
		
			
				|  |  | -        formList.sort((contract1, contract2) -> {
 | 
	
		
			
				|  |  | -            String name1 = contract1.getNodeName().replaceAll("\\s+", "");
 | 
	
		
			
				|  |  | -            String name2 = contract2.getNodeName().replaceAll("\\s+", "");
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -            boolean isCopy1 = name1.contains("__");
 | 
	
		
			
				|  |  | -            boolean isCopy2 = name2.contains("__");
 | 
	
		
			
				|  |  | +        // 2. 第一步:批量收集所有主表,创建分组(按主表在formList中的原始顺序)
 | 
	
		
			
				|  |  | +        Map<String, List<WbsTreeContract>> mainCopyGroupMap = new LinkedHashMap<>(); // 保持主表原始顺序
 | 
	
		
			
				|  |  | +        List<WbsTreeContract> copyTableList = new ArrayList<>(); // 临时存储所有复制表
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +            // 2.1 第一次遍历:分离主表和复制表
 | 
	
		
			
				|  |  | +        for (WbsTreeContract contract : formList) {
 | 
	
		
			
				|  |  | +            String nodeName = contract.getNodeName();
 | 
	
		
			
				|  |  | +            boolean isCopy = nodeName.contains("__");
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +            if (isCopy) {
 | 
	
		
			
				|  |  | +                // 复制表:先暂存,不立即匹配
 | 
	
		
			
				|  |  | +                copyTableList.add(contract);
 | 
	
		
			
				|  |  | +            } else {
 | 
	
		
			
				|  |  | +                // 主表:创建分组(按原始顺序插入)
 | 
	
		
			
				|  |  | +                String matchableMainName = getMatchableMainName(nodeName);
 | 
	
		
			
				|  |  | +                if (!mainCopyGroupMap.containsKey(matchableMainName)) {
 | 
	
		
			
				|  |  | +                    List<WbsTreeContract> groupList = new ArrayList<>();
 | 
	
		
			
				|  |  | +                    groupList.add(contract); // 主表放在分组第一位
 | 
	
		
			
				|  |  | +                    mainCopyGroupMap.put(matchableMainName, groupList);
 | 
	
		
			
				|  |  | +                } else {
 | 
	
		
			
				|  |  | +                    // 重复主表:仍放在分组第一位,保证主表优先
 | 
	
		
			
				|  |  | +                    mainCopyGroupMap.get(matchableMainName).add(0, contract);
 | 
	
		
			
				|  |  | +                }
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -            // 如果一个是主表,一个是复制表,主表排在前面
 | 
	
		
			
				|  |  | -            if (isCopy1 != isCopy2) {
 | 
	
		
			
				|  |  | -                return isCopy1 ? 1 : -1;
 | 
	
		
			
				|  |  | +            // 3. 第二步:批量处理所有复制表,匹配主表分组
 | 
	
		
			
				|  |  | +        List<WbsTreeContract> orphanCopyList = new ArrayList<>();
 | 
	
		
			
				|  |  | +        for (WbsTreeContract copyContract : copyTableList) {
 | 
	
		
			
				|  |  | +            String nodeName = copyContract.getNodeName();
 | 
	
		
			
				|  |  | +            String[] parts = nodeName.split("__", 2);
 | 
	
		
			
				|  |  | +            if (parts.length < 2) {
 | 
	
		
			
				|  |  | +                orphanCopyList.add(copyContract);
 | 
	
		
			
				|  |  | +                continue;
 | 
	
		
			
				|  |  |              }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -            // 如果都是主表或都是复制表
 | 
	
		
			
				|  |  | -            if (!isCopy1 && !isCopy2) {
 | 
	
		
			
				|  |  | -                // 都是主表,按字母顺序排序
 | 
	
		
			
				|  |  | -                return name1.compareTo(name2);
 | 
	
		
			
				|  |  | -            } else {
 | 
	
		
			
				|  |  | -                // 都是复制表,按主表名称和数字排序
 | 
	
		
			
				|  |  | -                String[] parts1 = name1.split("__", 2);
 | 
	
		
			
				|  |  | -                String[] parts2 = name2.split("__", 2);
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -                // 先比较主表名称
 | 
	
		
			
				|  |  | -                int mainNameCompare = parts1[0].compareTo(parts2[0]);
 | 
	
		
			
				|  |  | -                if (mainNameCompare != 0) {
 | 
	
		
			
				|  |  | -                    return mainNameCompare;
 | 
	
		
			
				|  |  | +            // 复制表拆分+统一格式,匹配主表分组
 | 
	
		
			
				|  |  | +            String rawMainName = parts[0];
 | 
	
		
			
				|  |  | +            String matchableMainName = getMatchableMainName(rawMainName);
 | 
	
		
			
				|  |  | +            boolean foundMatch = false;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +            // 遍历主表分组,找匹配的分组
 | 
	
		
			
				|  |  | +            for (Map.Entry<String, List<WbsTreeContract>> groupEntry : mainCopyGroupMap.entrySet()) {
 | 
	
		
			
				|  |  | +                if (groupEntry.getKey().equals(matchableMainName)) {
 | 
	
		
			
				|  |  | +                    groupEntry.getValue().add(copyContract); // 加入对应主表分组
 | 
	
		
			
				|  |  | +                    foundMatch = true;
 | 
	
		
			
				|  |  | +                    break;
 | 
	
		
			
				|  |  |                  }
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -                // 主表名称相同,比较数字部分
 | 
	
		
			
				|  |  | +            if (!foundMatch) {
 | 
	
		
			
				|  |  | +                orphanCopyList.add(copyContract);
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +            // 4. 第三步:处理每个主表分组,对复制表排序(保持主表原始顺序)
 | 
	
		
			
				|  |  | +        List<WbsTreeContract> sortedList = new ArrayList<>();
 | 
	
		
			
				|  |  | +        for (Map.Entry<String, List<WbsTreeContract>> entry : mainCopyGroupMap.entrySet()) {
 | 
	
		
			
				|  |  | +            List<WbsTreeContract> groupList = entry.getValue();
 | 
	
		
			
				|  |  | +            if (groupList.size() <= 1) {
 | 
	
		
			
				|  |  | +                // 只有主表,直接加入
 | 
	
		
			
				|  |  | +                sortedList.addAll(groupList);
 | 
	
		
			
				|  |  | +                continue;
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +            // 拆分主表(第一位)和复制表(剩余)
 | 
	
		
			
				|  |  | +            WbsTreeContract mainContract = groupList.get(0);
 | 
	
		
			
				|  |  | +            List<WbsTreeContract> copyList = groupList.subList(1, groupList.size());
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +            // 复制表按数字排序
 | 
	
		
			
				|  |  | +            copyList.sort((c1, c2) -> {
 | 
	
		
			
				|  |  | +                String numStr1 = c1.getNodeName().split("__", 2)[1];
 | 
	
		
			
				|  |  | +                String numStr2 = c2.getNodeName().split("__", 2)[1];
 | 
	
		
			
				|  |  |                  try {
 | 
	
		
			
				|  |  | -                    int num1 = Integer.parseInt(parts1[1]);
 | 
	
		
			
				|  |  | -                    int num2 = Integer.parseInt(parts2[1]);
 | 
	
		
			
				|  |  | -                    return Integer.compare(num1, num2);
 | 
	
		
			
				|  |  | +                    return Integer.compare(Integer.parseInt(numStr1), Integer.parseInt(numStr2));
 | 
	
		
			
				|  |  |                  } catch (NumberFormatException e) {
 | 
	
		
			
				|  |  | -                    // 如果解析数字失败,按字符串比较
 | 
	
		
			
				|  |  | -                    return parts1[1].compareTo(parts2[1]);
 | 
	
		
			
				|  |  | +                    return numStr1.compareTo(numStr2);
 | 
	
		
			
				|  |  |                  }
 | 
	
		
			
				|  |  | -            }
 | 
	
		
			
				|  |  | -        });
 | 
	
		
			
				|  |  | +            });
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +            // 主表 + 排序后的复制表,加入最终列表
 | 
	
		
			
				|  |  | +            sortedList.add(mainContract);
 | 
	
		
			
				|  |  | +            sortedList.addAll(copyList);
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        // 5. 第四步:加入无对应主表的复制表(排在最后)
 | 
	
		
			
				|  |  | +        sortedList.addAll(orphanCopyList);
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        // 6. 替换原列表
 | 
	
		
			
				|  |  | +        formList = sortedList;
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |          // 获取节点及祖先节点信息用于构建文件名
 | 
	
		
			
				|  |  |          WbsTreeContract node = wbsTreeContractServiceImpl.getById(nodeId);
 | 
	
	
		
			
				|  | @@ -553,21 +611,15 @@ public class WbsTreeContractController extends BladeController {
 | 
	
		
			
				|  |  |                  throw new ServiceException("所有表单均无法生成有效Excel内容");
 | 
	
		
			
				|  |  |              }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -            // 处理包含特殊符号的文件名(如#、空格、中文等)
 | 
	
		
			
				|  |  | -             String originalFileName = excelName + ".xlsx";
 | 
	
		
			
				|  |  | -              // 使用UTF-8编码,并替换特殊字符(符合RFC 5987标准)
 | 
	
		
			
				|  |  | +            String originalFileName = excelName + ".xlsx";
 | 
	
		
			
				|  |  | +                // 使用UTF-8编码,并替换特殊字符(符合RFC 5987标准)
 | 
	
		
			
				|  |  |              String encodedFileName = URLEncoder.encode(originalFileName, StandardCharsets.UTF_8.name())
 | 
	
		
			
				|  |  |                      .replaceAll("\\+", "%20") // 空格编码为%20(而非+)
 | 
	
		
			
				|  |  | -                    .replaceAll("%23", "#")
 | 
	
		
			
				|  |  | -                    .replaceAll("%26", "&"); // 保留#不编码(或根据需求调整)
 | 
	
		
			
				|  |  | +                    .replaceAll("%23", "#"); // 保留#不编码(或根据需求调整)
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |                // 设置响应头,采用RFC 5987标准格式(指定字符集)
 | 
	
		
			
				|  |  | -              // 双格式响应头:同时支持旧版和新版浏览器
 | 
	
		
			
				|  |  | -             response.setHeader("Content-Disposition",
 | 
	
		
			
				|  |  | -                    "attachment; " +
 | 
	
		
			
				|  |  | -                            "filename=\"" + encodedFileName + "\"; " +  // 旧格式(部分浏览器依赖)
 | 
	
		
			
				|  |  | -                            "filename*=UTF-8''" + encodedFileName       // 新标准格式
 | 
	
		
			
				|  |  | -             );
 | 
	
		
			
				|  |  | +            response.setHeader("Content-Disposition",
 | 
	
		
			
				|  |  | +                    "attachment; filename*=" + encodedFileName);
 | 
	
		
			
				|  |  |              response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |              // 写入输出流
 | 
	
	
		
			
				|  | @@ -829,12 +881,10 @@ public class WbsTreeContractController extends BladeController {
 | 
	
		
			
				|  |  |                      continue;
 | 
	
		
			
				|  |  |                  }
 | 
	
		
			
				|  |  |                  Map<String, String> dataMap = getDataMap(sheetResult);
 | 
	
		
			
				|  |  | -                // 对所有value中的单引号进行转义处理
 | 
	
		
			
				|  |  |                  for (Map.Entry<String, String> entry : dataMap.entrySet()) {
 | 
	
		
			
				|  |  |                      String value = entry.getValue();
 | 
	
		
			
				|  |  | -                    if (value != null && value.contains("''")) {
 | 
	
		
			
				|  |  | -                        // 将单引号转义为 \'
 | 
	
		
			
				|  |  | -                        value = value.replaceAll("'", "''");
 | 
	
		
			
				|  |  | +                    if (value != null) {
 | 
	
		
			
				|  |  | +                        value = value.replace("'", "''");
 | 
	
		
			
				|  |  |                          entry.setValue(value);
 | 
	
		
			
				|  |  |                      }
 | 
	
		
			
				|  |  |                  }
 |