Forráskód Böngészése

知识库,积分导出功能版本调整

徐滕 2 hete
szülő
commit
0971278de6

+ 369 - 1
src/main/java/com/jeeplus/common/utils/excel/ExportKnowledgeBaseExcel.java

@@ -4,6 +4,7 @@ import com.jeeplus.modules.sys.utils.DictUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.poi.ss.usermodel.*;
 import org.apache.poi.ss.util.CellRangeAddress;
+import org.apache.poi.ss.util.RegionUtil;
 import org.apache.poi.xssf.streaming.SXSSFWorkbook;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -75,6 +76,34 @@ public class ExportKnowledgeBaseExcel {
 	}
 
 	/**
+	 * 内部类:存储三层表头Sheet的数据
+	 */
+	private static class TripleHeaderSheetData {
+		String sheetName;
+		String title;
+		List<String> prefixHeaders;                  // 前缀列(格式:"中文名|mapKey")
+		List<String> firstLevelHeaders;              // 第一层表头名称列表
+		List<List<String>> secondLevelHeaders;       // 第二层表头(每个第一层对应一个子列列表)
+		List<List<List<String>>> thirdLevelHeaders;  // 第三层表头(每个第二层子列对应一个规则列表,空列表表示无第三层)
+		List<Map<String, Object>> dataList;
+
+		public TripleHeaderSheetData(String sheetName, String title,
+									 List<String> prefixHeaders,
+									 List<String> firstLevelHeaders,
+									 List<List<String>> secondLevelHeaders,
+									 List<List<List<String>>> thirdLevelHeaders,
+									 List<Map<String, Object>> dataList) {
+			this.sheetName = sheetName;
+			this.title = title;
+			this.prefixHeaders = prefixHeaders;
+			this.firstLevelHeaders = firstLevelHeaders;
+			this.secondLevelHeaders = secondLevelHeaders;
+			this.thirdLevelHeaders = thirdLevelHeaders;
+			this.dataList = dataList;
+		}
+	}
+
+	/**
 	 * 构造函数:初始化工作簿
 	 */
 	public ExportKnowledgeBaseExcel() {
@@ -107,6 +136,25 @@ public class ExportKnowledgeBaseExcel {
 	}
 
 	/**
+	 * 添加三层表头Sheet页
+	 * @param sheetName Sheet名称
+	 * @param title 标题
+	 * @param prefixHeaders 前缀列(格式:"中文名|mapKey")
+	 * @param firstLevelHeaders 第一层表头名称列表
+	 * @param secondLevelHeaders 第二层表头(每个第一层对应一个子列列表)
+	 * @param thirdLevelHeaders 第三层表头(每个第二层子列对应一个规则列表,空列表表示无第三层)
+	 * @param dataList 数据列表
+	 */
+	public void addTripleHeaderSheet(String sheetName, String title,
+									 List<String> prefixHeaders,
+									 List<String> firstLevelHeaders,
+									 List<List<String>> secondLevelHeaders,
+									 List<List<List<String>>> thirdLevelHeaders,
+									 List<Map<String, Object>> dataList) {
+		mapSheetDataList.add(new TripleHeaderSheetData(sheetName, title, prefixHeaders, firstLevelHeaders, secondLevelHeaders, thirdLevelHeaders, dataList));
+	}
+
+	/**
 	 * 创建单元格样式
 	 */
 	private Map<String, CellStyle> createStyles(Workbook wb) {
@@ -233,6 +281,71 @@ public class ExportKnowledgeBaseExcel {
 		numberStyle.setFont(numberFont);
 		styles.put("data_number", numberStyle);
 
+		// === 三层表头专用样式 ===
+		// L1表头样式(深蓝底色 + 粗体白字 + MEDIUM边框)
+		CellStyle l1Style = wb.createCellStyle();
+		l1Style.setAlignment(HorizontalAlignment.CENTER);
+		l1Style.setVerticalAlignment(VerticalAlignment.CENTER);
+		l1Style.setBorderBottom(BorderStyle.MEDIUM);
+		l1Style.setBorderLeft(BorderStyle.MEDIUM);
+		l1Style.setBorderRight(BorderStyle.MEDIUM);
+		l1Style.setBorderTop(BorderStyle.MEDIUM);
+		l1Style.setFillForegroundColor(IndexedColors.GREY_50_PERCENT.getIndex());
+		l1Style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+		Font l1Font = wb.createFont();
+		l1Font.setFontName("宋体");
+		l1Font.setFontHeightInPoints((short) 11);
+		l1Font.setBold(true);
+		l1Font.setColor(IndexedColors.WHITE.getIndex());
+		l1Style.setFont(l1Font);
+		styles.put("l1_header", l1Style);
+
+		// L2表头样式(两种颜色交替:浅蓝/浅橙 + 粗体黑字 + THIN边框)
+		short[] l2Colors = {
+			IndexedColors.PALE_BLUE.getIndex(),     // 偶数索引组:浅蓝
+			IndexedColors.LIGHT_ORANGE.getIndex()   // 奇数索引组:浅橙
+		};
+		for (int c = 0; c < l2Colors.length; c++) {
+			CellStyle l2Style = wb.createCellStyle();
+			l2Style.setAlignment(HorizontalAlignment.CENTER);
+			l2Style.setVerticalAlignment(VerticalAlignment.CENTER);
+			l2Style.setBorderBottom(BorderStyle.THIN);
+			l2Style.setBorderLeft(BorderStyle.THIN);
+			l2Style.setBorderRight(BorderStyle.THIN);
+			l2Style.setBorderTop(BorderStyle.THIN);
+			l2Style.setFillForegroundColor(l2Colors[c]);
+			l2Style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+			Font l2Font = wb.createFont();
+			l2Font.setFontName("宋体");
+			l2Font.setFontHeightInPoints((short) 11);
+			l2Font.setBold(true);
+			l2Style.setFont(l2Font);
+			styles.put("l2_header_group_" + c, l2Style);
+		}
+
+		// L3表头样式(两种颜色交替:浅灰/浅黄 + 粗体黑字 + THIN边框)
+		short[] l3Colors = {
+			IndexedColors.GREY_25_PERCENT.getIndex(), // 偶数索引组:浅灰
+			IndexedColors.LEMON_CHIFFON.getIndex()    // 奇数索引组:浅黄
+		};
+		for (int c = 0; c < l3Colors.length; c++) {
+			CellStyle l3Style = wb.createCellStyle();
+			l3Style.setAlignment(HorizontalAlignment.CENTER);
+			l3Style.setVerticalAlignment(VerticalAlignment.CENTER);
+			l3Style.setBorderBottom(BorderStyle.THIN);
+			l3Style.setBorderLeft(BorderStyle.THIN);
+			l3Style.setBorderRight(BorderStyle.THIN);
+			l3Style.setBorderTop(BorderStyle.THIN);
+			l3Style.setFillForegroundColor(l3Colors[c]);
+			l3Style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+			Font l3Font = wb.createFont();
+			l3Font.setFontName("宋体");
+			l3Font.setFontHeightInPoints((short) 10);
+			l3Font.setBold(true);
+			l3Style.setFont(l3Font);
+			styles.put("l3_header_group_" + c, l3Style);
+		}
+
 		return styles;
 	}
 
@@ -241,7 +354,9 @@ public class ExportKnowledgeBaseExcel {
 	 */
 	public void write(OutputStream os) throws IOException {
 		for (Object sheetData : mapSheetDataList) {
-			if (sheetData instanceof DoubleHeaderSheetData) {
+			if (sheetData instanceof TripleHeaderSheetData) {
+				createTripleHeaderSheetContent((TripleHeaderSheetData) sheetData);
+			} else if (sheetData instanceof DoubleHeaderSheetData) {
 				createDoubleHeaderSheetContent((DoubleHeaderSheetData) sheetData);
 			} else if (sheetData instanceof MapSheetData) {
 				createMapSheetContent((MapSheetData) sheetData);
@@ -472,6 +587,259 @@ public class ExportKnowledgeBaseExcel {
 	}
 
 	/**
+	 * 创建三层表头Sheet的内容
+	 * 支持:前缀列 + 上传文档数量 + L1(父分类) → L2(子分类+兑现) → L3(规则名) + 小计
+	 */
+	private void createTripleHeaderSheetContent(TripleHeaderSheetData sheetData) {
+		Sheet sheet = workbook.createSheet(sheetData.sheetName);
+		Map<String, CellStyle> styles = createStyles(workbook);
+		List<String> prefixHeaders = sheetData.prefixHeaders != null ? sheetData.prefixHeaders : new ArrayList<>();
+		int prefixCount = prefixHeaders.size();
+		int rownum = 0;
+
+		// 计算总列数 = 前缀列 + 上传文档数量(1) + 各L1组列数
+		int totalColumns = prefixCount + 1; // +1 for 上传文档数量
+		for (int i = 0; i < sheetData.firstLevelHeaders.size(); i++) {
+			List<String> l2Headers = sheetData.secondLevelHeaders.get(i);
+			List<List<String>> l3HeadersForL1 = sheetData.thirdLevelHeaders.get(i);
+			for (int j = 0; j < l2Headers.size(); j++) {
+				List<String> l3Headers = l3HeadersForL1.get(j);
+				if (l3Headers.isEmpty()) {
+					totalColumns += 1; // 汇总/兑换 or leaf L2
+				} else {
+					totalColumns += l3Headers.size();
+				}
+			}
+			// 注意:不再单独添加小计列,因为"汇总"已经在secondLevelHeaders中
+		}
+
+		// 创建标题行
+		if (StringUtils.isNotBlank(sheetData.title)) {
+			Row titleRow = sheet.createRow(rownum++);
+			titleRow.setHeightInPoints(30);
+			Cell titleCell = titleRow.createCell(0);
+			titleCell.setCellStyle(styles.get("title"));
+			titleCell.setCellValue(sheetData.title);
+			sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, totalColumns - 1));
+		}
+
+		// 三行表头的起始行号
+		int headerRow1 = rownum;
+		int headerRow2 = rownum + 1;
+		int headerRow3 = rownum + 2;
+		Row l1Row = sheet.createRow(rownum++);
+		Row l2Row = sheet.createRow(rownum++);
+		Row l3Row = sheet.createRow(rownum++);
+		l1Row.setHeightInPoints(20);
+		l2Row.setHeightInPoints(18);
+		l3Row.setHeightInPoints(18);
+
+		int currentCol = 0;
+
+		// === 前缀列(跨3行合并)===
+		for (int p = 0; p < prefixCount; p++) {
+			CellRangeAddress mergeRegion = new CellRangeAddress(headerRow1, headerRow3, currentCol, currentCol);
+			sheet.addMergedRegion(mergeRegion);
+			Cell cell = l1Row.createCell(currentCol);
+			cell.setCellStyle(styles.get("prefix_header"));
+			String display = prefixHeaders.get(p).contains("|") ? prefixHeaders.get(p).split("\\|")[0] : prefixHeaders.get(p);
+			cell.setCellValue(display);
+			int width = Math.max(display.length() * 2 + 2, 8);
+			sheet.setColumnWidth(currentCol, width * 256);
+			// 为合并区域设置完整边框
+			RegionUtil.setBorderTop(CellStyle.BORDER_THIN, mergeRegion, sheet, workbook);
+			RegionUtil.setBorderBottom(CellStyle.BORDER_THIN, mergeRegion, sheet, workbook);
+			RegionUtil.setBorderLeft(CellStyle.BORDER_THIN, mergeRegion, sheet, workbook);
+			RegionUtil.setBorderRight(CellStyle.BORDER_THIN, mergeRegion, sheet, workbook);
+			currentCol++;
+		}
+
+		// === 上传文档数量列(跨3行合并)===
+		CellRangeAddress uploadMerge = new CellRangeAddress(headerRow1, headerRow3, currentCol, currentCol);
+		sheet.addMergedRegion(uploadMerge);
+		Cell uploadCell = l1Row.createCell(currentCol);
+		uploadCell.setCellStyle(styles.get("prefix_header"));
+		uploadCell.setCellValue("上传文档数量");
+		sheet.setColumnWidth(currentCol, 14 * 256);
+		// 为合并区域设置完整边框
+		RegionUtil.setBorderTop(CellStyle.BORDER_THIN, uploadMerge, sheet, workbook);
+		RegionUtil.setBorderBottom(CellStyle.BORDER_THIN, uploadMerge, sheet, workbook);
+		RegionUtil.setBorderLeft(CellStyle.BORDER_THIN, uploadMerge, sheet, workbook);
+		RegionUtil.setBorderRight(CellStyle.BORDER_THIN, uploadMerge, sheet, workbook);
+		currentCol++;
+
+		// === L1/L2/L3 表头 + 数据列 ===
+		for (int i = 0; i < sheetData.firstLevelHeaders.size(); i++) {
+			String l1Name = sheetData.firstLevelHeaders.get(i);
+			List<String> l2Headers = sheetData.secondLevelHeaders.get(i);
+			List<List<String>> l3HeadersForL1 = sheetData.thirdLevelHeaders.get(i);
+			int l1StartCol = currentCol;
+
+			// 计算此L1组的总列数
+			int l1Span = 0;
+			for (int j = 0; j < l2Headers.size(); j++) {
+				List<String> l3Headers = l3HeadersForL1.get(j);
+				l1Span += l3Headers.isEmpty() ? 1 : l3Headers.size();
+			}
+			// 注意:不再单独添加小计列,因为"汇总"已经在secondLevelHeaders中
+
+			// L1表头(跨列合并)
+			CellRangeAddress l1Merge = new CellRangeAddress(headerRow1, headerRow1, l1StartCol, l1StartCol + l1Span - 1);
+			sheet.addMergedRegion(l1Merge);
+			Cell l1Cell = l1Row.createCell(l1StartCol);
+			l1Cell.setCellStyle(styles.get("l1_header"));
+			l1Cell.setCellValue(l1Name);
+			// 为合并区域设置完整边框
+			RegionUtil.setBorderTop(CellStyle.BORDER_MEDIUM, l1Merge, sheet, workbook);
+			RegionUtil.setBorderBottom(CellStyle.BORDER_MEDIUM, l1Merge, sheet, workbook);
+			RegionUtil.setBorderLeft(CellStyle.BORDER_MEDIUM, l1Merge, sheet, workbook);
+			RegionUtil.setBorderRight(CellStyle.BORDER_MEDIUM, l1Merge, sheet, workbook);
+
+			// 根据L1索引选择表头样式(偶数组用group_0,奇数组用group_1)
+			int groupIdx = i % 2;
+			String l2HeaderStyle = "l2_header_group_" + groupIdx;
+			String l3HeaderStyle = "l3_header_group_" + groupIdx;
+
+			// L2/L3 表头
+			for (int j = 0; j < l2Headers.size(); j++) {
+				String l2Name = l2Headers.get(j);
+				List<String> l3Headers = l3HeadersForL1.get(j);
+
+				if (l3Headers.isEmpty()) {
+					// 无L3子列(如"兑换积分"),L2跨行合并到L3行
+					// 先创建L2和L3行的单元格,再合并,确保边框完整
+					Cell l2Cell = l2Row.createCell(currentCol);
+					l2Cell.setCellStyle(styles.get(l2HeaderStyle));
+					l2Cell.setCellValue(l2Name);
+					Cell l3Cell = l3Row.createCell(currentCol);
+					l3Cell.setCellStyle(styles.get(l2HeaderStyle)); // L3行也使用相同样式
+					CellRangeAddress l2Merge = new CellRangeAddress(headerRow2, headerRow3, currentCol, currentCol);
+					sheet.addMergedRegion(l2Merge);
+					// 为合并区域设置完整边框
+					RegionUtil.setBorderTop(CellStyle.BORDER_THIN, l2Merge, sheet, workbook);
+					RegionUtil.setBorderBottom(CellStyle.BORDER_THIN, l2Merge, sheet, workbook);
+					RegionUtil.setBorderLeft(CellStyle.BORDER_THIN, l2Merge, sheet, workbook);
+					RegionUtil.setBorderRight(CellStyle.BORDER_THIN, l2Merge, sheet, workbook);
+					int w = Math.max(l2Name.length() * 2 + 4, 12);
+					sheet.setColumnWidth(currentCol, w * 256);
+					currentCol++;
+				} else {
+					// 有L3子列,L2跨列合并
+					int l2StartCol = currentCol;
+					if (l3Headers.size() > 1) {
+						CellRangeAddress l2Merge = new CellRangeAddress(headerRow2, headerRow2, l2StartCol, l2StartCol + l3Headers.size() - 1);
+						sheet.addMergedRegion(l2Merge);
+						// 为合并区域设置完整边框
+						RegionUtil.setBorderTop(CellStyle.BORDER_THIN, l2Merge, sheet, workbook);
+						RegionUtil.setBorderBottom(CellStyle.BORDER_THIN, l2Merge, sheet, workbook);
+						RegionUtil.setBorderLeft(CellStyle.BORDER_THIN, l2Merge, sheet, workbook);
+						RegionUtil.setBorderRight(CellStyle.BORDER_THIN, l2Merge, sheet, workbook);
+					}
+					Cell l2Cell = l2Row.createCell(l2StartCol);
+					l2Cell.setCellStyle(styles.get(l2HeaderStyle));
+					l2Cell.setCellValue(l2Name);
+
+					// L3叶子列表头
+					for (String l3Name : l3Headers) {
+						Cell l3Cell = l3Row.createCell(currentCol);
+						l3Cell.setCellStyle(styles.get(l3HeaderStyle));
+						l3Cell.setCellValue(l3Name);
+						int charW = 0;
+						for (char c : l3Name.toCharArray()) {
+							charW += (c > 127) ? 2 : 1;
+						}
+						int colW = Math.max(charW + 4, 10);
+						colW = Math.min(colW, 24);
+						sheet.setColumnWidth(currentCol, colW * 256);
+						currentCol++;
+					}
+				}
+			}
+			// 注意:不再单独添加"汇总"列,因为它已经在secondLevelHeaders中作为第一列存在了
+		}
+
+		// === 填充数据行 ===
+		if (sheetData.dataList != null && !sheetData.dataList.isEmpty()) {
+			for (Map<String, Object> dataMap : sheetData.dataList) {
+				Row dataRow = sheet.createRow(rownum++);
+				dataRow.setHeightInPoints(16);
+				currentCol = 0;
+
+				// 前缀列数据
+				for (String prefixHeader : prefixHeaders) {
+					String mapKey = prefixHeader.contains("|") ? prefixHeader.split("\\|")[1] : prefixHeader;
+					Object value = dataMap.get(mapKey);
+					Cell cell = dataRow.createCell(currentCol++);
+					if (value instanceof Number) {
+						cell.setCellStyle(styles.get("data_number"));
+					} else {
+						cell.setCellStyle(styles.get("data_string"));
+					}
+					setMapCellValue(cell, value);
+				}
+
+				// 上传文档数量
+				Object uploadVal = dataMap.get("uploadCount");
+				Cell uploadDataCell = dataRow.createCell(currentCol++);
+				if (uploadVal instanceof Number) {
+					uploadDataCell.setCellStyle(styles.get("data_number"));
+				} else {
+					uploadDataCell.setCellStyle(styles.get("data_string"));
+				}
+				setMapCellValue(uploadDataCell, uploadVal);
+
+				// L1/L2/L3 数据列(按L1组交替底色)
+				for (int i = 0; i < sheetData.firstLevelHeaders.size(); i++) {
+					List<String> l2Headers = sheetData.secondLevelHeaders.get(i);
+					List<List<String>> l3HeadersForL1 = sheetData.thirdLevelHeaders.get(i);
+					int groupIdx = i % 2;
+
+					// 各列的数据key由Controller在dataMap中预设
+					for (int j = 0; j < l2Headers.size(); j++) {
+						List<String> l3Headers = l3HeadersForL1.get(j);
+						if (l3Headers.isEmpty()) {
+							// 判断是否为"积分汇总"或"剩余积分"列
+							String l2Name = l2Headers.get(j);
+							Object value;
+							if ("积分汇总".equals(l2Name)) {
+								// 积分汇总列使用 subtotal_key
+								value = dataMap.get("subtotal_" + i);
+							} else if ("剩余积分".equals(l2Name)) {
+								// 剩余积分列使用 remaining_key
+								value = dataMap.get("remaining_" + i);
+							} else {
+								// 其他无L3列(如已兑换积分、阅读、点赞等)使用 exchange_key
+								String dataKey = "exchange_" + i + "_" + j;
+								value = dataMap.get(dataKey);
+							}
+							Cell cell = dataRow.createCell(currentCol++);
+							if (value instanceof Number) {
+								cell.setCellStyle(styles.get("data_number_group_" + groupIdx));
+							} else {
+								cell.setCellStyle(styles.get("data_string_group_" + groupIdx));
+							}
+							setMapCellValue(cell, value);
+						} else {
+							for (int k = 0; k < l3Headers.size(); k++) {
+								String dataKey = "rule_" + i + "_" + j + "_" + k;
+								Object value = dataMap.get(dataKey);
+								Cell cell = dataRow.createCell(currentCol++);
+								if (value instanceof Number) {
+									cell.setCellStyle(styles.get("data_number_group_" + groupIdx));
+								} else {
+									cell.setCellStyle(styles.get("data_string_group_" + groupIdx));
+								}
+								setMapCellValue(cell, value);
+							}
+						}
+					}
+					// 注意:不再单独添加小计列,因为"汇总"已经在secondLevelHeaders中作为第一列存在了
+				}
+			}
+		}
+	}
+
+	/**
 	 * 输出到客户端下载
 	 */
 	public void write(HttpServletResponse response, String fileName) throws IOException {

+ 27 - 0
src/main/java/com/jeeplus/modules/WorkKnowledgeBase/dao/WorkKnowledgeBasePointDetailDao.java

@@ -171,4 +171,31 @@ public interface WorkKnowledgeBasePointDetailDao extends CrudDao<WorkKnowledgeBa
         @Param("beginDate") String beginDate,
         @Param("endDate") String endDate
     );
+
+    /**
+     * 按rule_id分组统计积分总和(用于三层表头导出)
+     * 返回每个用户在每个L2分类下每个规则的积分总和
+     * @param userIds 用户ID列表
+     * @param beginDate 开始日期(可选)
+     * @param endDate 结束日期(可选)
+     * @return Map列表,每个Map包含userId、categoryId、parentId、ruleId、totalPoint
+     */
+    List<java.util.Map<String, Object>> sumPointsByRuleGrouped(
+        @Param("userIds") List<String> userIds,
+        @Param("beginDate") String beginDate,
+        @Param("endDate") String endDate
+    );
+
+    /**
+     * 按L1分类统计兑换积分绝对值总和(用于三层表头的兑现列)
+     * @param userIds 用户ID列表
+     * @param beginDate 开始日期(可选)
+     * @param endDate 结束日期(可选)
+     * @return Map列表,每个Map包含userId、firstLevelId、totalPoint
+     */
+    List<java.util.Map<String, Object>> sumExchangeByFirstLevelCategory(
+        @Param("userIds") List<String> userIds,
+        @Param("beginDate") String beginDate,
+        @Param("endDate") String endDate
+    );
 }

+ 7 - 0
src/main/java/com/jeeplus/modules/WorkKnowledgeBase/dao/WorkKnowledgeBasePointRuleDao.java

@@ -5,6 +5,8 @@ import com.jeeplus.common.persistence.annotation.MyBatisDao;
 import com.jeeplus.modules.WorkKnowledgeBase.entity.WorkKnowledgeBasePointRule;
 import org.apache.ibatis.annotations.Param;
 
+import java.util.List;
+
 /**
  * 知识库积分规则 DAO
  * @author: 徐滕
@@ -40,4 +42,9 @@ public interface WorkKnowledgeBasePointRuleDao extends CrudDao<WorkKnowledgeBase
         @Param("ruleType") String ruleType,
         @Param("pointName") String pointName
     );
+
+    /**
+     * 根据分类ID精确匹配查询启用的规则,按rule_type升序排列(用于三层表头导出)
+     */
+    List<WorkKnowledgeBasePointRule> findEnabledByCategoryId(@Param("categoryId") String categoryId);
 }

+ 10 - 0
src/main/java/com/jeeplus/modules/WorkKnowledgeBase/dao/WorkKnowledgePointCategoryDao.java

@@ -22,6 +22,16 @@ public interface WorkKnowledgePointCategoryDao extends CrudDao<WorkKnowledgePoin
     List<WorkKnowledgePointCategory> findEnabledList();
 
     /**
+     * 查询第一层分类(parent_id=0,启用状态,排除id=9)
+     */
+    List<WorkKnowledgePointCategory> findFirstLevelList();
+
+    /**
+     * 查询指定父节点下的子分类(启用状态)
+     */
+    List<WorkKnowledgePointCategory> findChildrenByParentId(@Param("parentId") String parentId);
+
+    /**
      * 根据parentIds查询所有子分类(用于删除校验)
      */
     List<WorkKnowledgePointCategory> findByParentIdsLike(@Param("parentIds") String parentIds);

+ 28 - 0
src/main/java/com/jeeplus/modules/WorkKnowledgeBase/service/WorkKnowledgeBasePointDetailService.java

@@ -176,4 +176,32 @@ public class WorkKnowledgeBasePointDetailService extends CrudService<WorkKnowled
         }
         return pointDetailDao.countAuditDocsByTreeNodeIds(treeNames, userIds, beginDate, endDate);
     }
+
+    /**
+     * 按rule_id分组统计积分总和(用于三层表头导出)
+     * @param userIds 用户ID列表
+     * @param beginDate 开始日期(可选)
+     * @param endDate 结束日期(可选)
+     * @return Map列表,每个Map包含userId、categoryId、parentId、ruleId、totalPoint
+     */
+    public List<java.util.Map<String, Object>> sumPointsByRuleGrouped(List<String> userIds, String beginDate, String endDate) {
+        if (userIds == null || userIds.isEmpty()) {
+            return new ArrayList<>();
+        }
+        return pointDetailDao.sumPointsByRuleGrouped(userIds, beginDate, endDate);
+    }
+
+    /**
+     * 按L1分类统计兑换积分绝对值总和(用于三层表头的兑现列)
+     * @param userIds 用户ID列表
+     * @param beginDate 开始日期(可选)
+     * @param endDate 结束日期(可选)
+     * @return Map列表,每个Map包含userId、firstLevelId、totalPoint
+     */
+    public List<java.util.Map<String, Object>> sumExchangeByFirstLevelCategory(List<String> userIds, String beginDate, String endDate) {
+        if (userIds == null || userIds.isEmpty()) {
+            return new ArrayList<>();
+        }
+        return pointDetailDao.sumExchangeByFirstLevelCategory(userIds, beginDate, endDate);
+    }
 }

+ 2 - 2
src/main/java/com/jeeplus/modules/WorkKnowledgeBase/service/WorkKnowledgeBasePointExchangeService.java

@@ -122,7 +122,7 @@ public class WorkKnowledgeBasePointExchangeService extends CrudService<WorkKnowl
         detail.setId(com.jeeplus.common.utils.IdGen.uuid());
         detail.setUserId(userId);
         detail.setChangeType(9); // 9表示兑换扣减
-        detail.setCategoryId("9"); // 兑换积分分类ID
+        detail.setCategoryId("1"); // 基础积分L1分类ID,使扣减自然计入基础积分的已兑换列
         detail.setPoint(-exchangeablePoints); // 负数表示扣减
         detail.setDelFlag("0");
         detail.preInsert();
@@ -155,7 +155,7 @@ public class WorkKnowledgeBasePointExchangeService extends CrudService<WorkKnowl
         detail.setId(com.jeeplus.common.utils.IdGen.uuid());
         detail.setUserId(userId);
         detail.setChangeType(9); // 9表示兑换扣减
-        detail.setCategoryId("9"); // 兑换积分分类ID
+        detail.setCategoryId("2"); // 贡献积分L1分类ID,使扣减自然计入贡献积分的已兑换列
         detail.setPoint(-contributionPoints); // 负数表示扣减,全额清零
         detail.setDelFlag("0");
         detail.preInsert();

+ 139 - 0
src/main/java/com/jeeplus/modules/WorkKnowledgeBase/service/WorkKnowledgePointCategoryService.java

@@ -5,12 +5,16 @@ import com.jeeplus.common.service.TreeService;
 import com.jeeplus.common.utils.StringUtils;
 import com.jeeplus.modules.WorkKnowledgeBase.dao.WorkKnowledgeBasePointRuleDao;
 import com.jeeplus.modules.WorkKnowledgeBase.dao.WorkKnowledgePointCategoryDao;
+import com.jeeplus.modules.WorkKnowledgeBase.entity.WorkKnowledgeBasePointRule;
 import com.jeeplus.modules.WorkKnowledgeBase.entity.WorkKnowledgePointCategory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * 积分分类树形 Service
@@ -59,6 +63,20 @@ public class WorkKnowledgePointCategoryService extends TreeService<WorkKnowledge
     }
 
     /**
+     * 查询第一层分类(parent_id=0,启用状态,排除id=9)
+     */
+    public List<WorkKnowledgePointCategory> findFirstLevelList() {
+        return categoryDao.findFirstLevelList();
+    }
+
+    /**
+     * 查询指定父节点下的子分类(启用状态)
+     */
+    public List<WorkKnowledgePointCategory> findChildrenByParentId(String parentId) {
+        return categoryDao.findChildrenByParentId(parentId);
+    }
+
+    /**
      * 保存分类(新增/编辑)- 重写父类方法增强校验
      * 自动拼接parentIds,切换父节点时递归更新所有子节点链路
      */
@@ -119,4 +137,125 @@ public class WorkKnowledgePointCategoryService extends TreeService<WorkKnowledge
         category.preUpdate();
         categoryDao.update(category);
     }
+
+    /**
+     * 获取三层表头结构(用于导出)
+     * 第一层:parent_id=0的父节点名称
+     * 第二层:"汇总" + 每个父节点下的子节点名称 + "兑换"列
+     * 第三层:与子节点关联的规则point_name
+     * 
+     * @return Map结构:{
+     *   "firstLevelHeaders": ["基础积分", "贡献积分"],  // 第一层表头列表
+     *   "secondLevelHeaders": [["汇总", "技术总结", "培训心得", "兑换"], ...],  // 第二层表头(每个第一层对应一个列表)
+     *   "thirdLevelHeaders": [[[], ["上传文档", "审核通过"], [], []], ...],  // 第三层表头(每个第二层对应一个列表,空列表表示无规则或汇总/兑换列)
+     *   "columnKeys": [{"key": "xxx", "type": "rule/exchange/upload/subtotal"}, ...]  // 数据列key映射
+     * }
+     */
+    public Map<String, Object> getThreeLevelHeaderStructure() {
+        Map<String, Object> result = new HashMap<>();
+        
+        // 1. 查询所有启用的父节点(parent_id=0,排除id=9)
+        List<WorkKnowledgePointCategory> firstLevelList = findFirstLevelList();
+        
+        List<String> firstLevelHeaders = new ArrayList<>();
+        List<List<String>> secondLevelHeaders = new ArrayList<>();
+        List<List<List<String>>> thirdLevelHeaders = new ArrayList<>();
+        List<Map<String, String>> columnKeys = new ArrayList<>();
+        List<String> l2LeafCategoryIds = new ArrayList<>(); // 记录无L3子列的L2叶子分类ID(不含"汇总"、"兑换")
+        
+        // 2. 遍历每个父节点
+        for (WorkKnowledgePointCategory firstLevel : firstLevelList) {
+            firstLevelHeaders.add(firstLevel.getCategoryName());
+            
+            // 3. 查询该父节点下的所有子节点
+            List<WorkKnowledgePointCategory> secondLevelList = findChildrenByParentId(firstLevel.getId());
+            
+            List<String> secondLevelNames = new ArrayList<>();
+            List<List<String>> thirdLevelForThisFirst = new ArrayList<>();
+            
+            // 6. 先添加明细列(阅读、点赞等)
+            if (secondLevelList.isEmpty()) {
+                // 无子分类(如"基础积分"),直接查询该L1分类下的规则作为L2表头
+                List<WorkKnowledgeBasePointRule> rules = pointRuleDao.findEnabledByCategoryId(firstLevel.getId());
+                            
+                for (WorkKnowledgeBasePointRule rule : rules) {
+                    secondLevelNames.add(rule.getPointName());
+                    thirdLevelForThisFirst.add(new ArrayList<>()); // 无L3
+                    Map<String, String> keyMap = new HashMap<>();
+                    keyMap.put("key", "directRule_" + rule.getId());
+                    keyMap.put("type", "directRule");
+                    keyMap.put("ruleId", rule.getId());
+                    keyMap.put("pointName", rule.getPointName());
+                    columnKeys.add(keyMap);
+                }
+            } else {
+                // 4. 遍历每个子节点
+                for (WorkKnowledgePointCategory secondLevel : secondLevelList) {
+                    secondLevelNames.add(secondLevel.getCategoryName());
+                                
+                    // 5. 查询该子节点关联的所有规则(point_name),按rule_type升序
+                    List<WorkKnowledgeBasePointRule> rules = pointRuleDao.findEnabledByCategoryId(secondLevel.getId());
+                                
+                    List<String> thirdLevelNames = new ArrayList<>();
+                    for (WorkKnowledgeBasePointRule rule : rules) {
+                        thirdLevelNames.add(rule.getPointName());
+                        Map<String, String> keyMap = new HashMap<>();
+                        keyMap.put("key", "rule_" + rule.getId());
+                        keyMap.put("type", "rule");
+                        keyMap.put("pointName", rule.getPointName());
+                        columnKeys.add(keyMap);
+                    }
+                    // 无L3子列的L2叶子(如"阅读"、"点赞"),记录其categoryId
+                    if (thirdLevelNames.isEmpty()) {
+                        Map<String, String> l2LeafKey = new HashMap<>();
+                        l2LeafKey.put("key", "l2leaf_" + secondLevel.getId());
+                        l2LeafKey.put("type", "l2leaf");
+                        l2LeafKey.put("categoryId", secondLevel.getId());
+                        l2LeafKey.put("categoryName", secondLevel.getCategoryName());
+                        columnKeys.add(l2LeafKey);
+                        l2LeafCategoryIds.add(secondLevel.getId());
+                    }
+                    thirdLevelForThisFirst.add(thirdLevelNames);
+                }
+            }
+            
+            // 7. 添加"积分汇总"列(明细列之后)
+            secondLevelNames.add("积分汇总");
+            thirdLevelForThisFirst.add(new ArrayList<>());
+            Map<String, String> subtotalKeyMap = new HashMap<>();
+            subtotalKeyMap.put("key", "subtotal_" + firstLevel.getId());
+            subtotalKeyMap.put("type", "subtotal");
+            subtotalKeyMap.put("categoryName", firstLevel.getCategoryName());
+            columnKeys.add(subtotalKeyMap);
+            
+            // 8. 添加"已兑换积分"列(积分汇总之后)
+            secondLevelNames.add("已兑换积分");
+            thirdLevelForThisFirst.add(new ArrayList<>());
+            Map<String, String> exchangeKeyMap = new HashMap<>();
+            exchangeKeyMap.put("key", "exchange_" + firstLevel.getId());
+            exchangeKeyMap.put("type", "exchange");
+            exchangeKeyMap.put("categoryName", firstLevel.getCategoryName());
+            columnKeys.add(exchangeKeyMap);
+            
+            // 9. 添加"剩余积分"列(已兑换积分之后)
+            secondLevelNames.add("剩余积分");
+            thirdLevelForThisFirst.add(new ArrayList<>());
+            Map<String, String> remainingKeyMap = new HashMap<>();
+            remainingKeyMap.put("key", "remaining_" + firstLevel.getId());
+            remainingKeyMap.put("type", "remaining");
+            remainingKeyMap.put("categoryName", firstLevel.getCategoryName());
+            columnKeys.add(remainingKeyMap);
+            
+            secondLevelHeaders.add(secondLevelNames);
+            thirdLevelHeaders.add(thirdLevelForThisFirst);
+        }
+        
+        result.put("firstLevelHeaders", firstLevelHeaders);
+        result.put("secondLevelHeaders", secondLevelHeaders);
+        result.put("thirdLevelHeaders", thirdLevelHeaders);
+        result.put("columnKeys", columnKeys);
+        result.put("l2LeafCategoryIds", l2LeafCategoryIds);
+        
+        return result;
+    }
 }

+ 261 - 272
src/main/java/com/jeeplus/modules/WorkKnowledgeBase/web/WorkKnowledgeBasePointController.java

@@ -12,10 +12,13 @@ import com.jeeplus.common.utils.excel.ExportKnowledgeBaseExcel;
 import com.jeeplus.common.web.BaseController;
 import com.jeeplus.modules.WorkKnowledgeBase.entity.UserPointDetail;
 import com.jeeplus.modules.WorkKnowledgeBase.entity.WorkKnowledgeBasePoint;
+import com.jeeplus.modules.WorkKnowledgeBase.entity.WorkKnowledgeBasePointRule;
+import com.jeeplus.modules.WorkKnowledgeBase.dao.WorkKnowledgeBasePointRuleDao;
 import com.jeeplus.modules.WorkKnowledgeBase.service.WorkKnowledgeBasePointDetailService;
 import com.jeeplus.modules.WorkKnowledgeBase.service.WorkKnowledgeBasePointExchangeService;
 import com.jeeplus.modules.WorkKnowledgeBase.service.WorkKnowledgeBasePointService;
 import com.jeeplus.modules.WorkKnowledgeBase.service.WorkKnowledgeBaseTreeService;
+import com.jeeplus.modules.WorkKnowledgeBase.service.WorkKnowledgePointCategoryService;
 import com.jeeplus.modules.sys.entity.User;
 import com.jeeplus.modules.sys.service.OfficeService;
 import com.jeeplus.modules.sys.utils.UserUtils;
@@ -61,6 +64,12 @@ public class WorkKnowledgeBasePointController extends BaseController {
     @Autowired
     private WorkKnowledgeBaseTreeService treeService;
 
+    @Autowired
+    private WorkKnowledgePointCategoryService workKnowledgePointCategoryService;
+
+    @Autowired
+    private WorkKnowledgeBasePointRuleDao pointRuleDao;
+
     @ModelAttribute
     public WorkKnowledgeBasePoint get(@RequestParam(required=false) String id) {
         WorkKnowledgeBasePoint entity = null;
@@ -302,17 +311,17 @@ public class WorkKnowledgeBasePointController extends BaseController {
     }
 
     /**
-     * 积分统计三级导出:公司级、部门级、员工级
-     * 列:上传文档数量、贡献积分额度、审核文档数量、审核积分额度、阅读积分额度、点赞积分额度、兑换积分额度
+     * 积分统计三层表头导出:公司级、部门级、员工级
+     * L1: parent_id=0的分类 → L2: 子分类 → L3: 规则point_name
+     * 每个L1组包含: L2子分类(L3规则列) + 兑现列 + 小计列
      */
     @RequiresPermissions("workKnowledgeBase:point:listAll")
     @RequestMapping(value = "exportPointStats")
     public void exportPointStats(WorkKnowledgeBasePoint queryParam, HttpServletResponse response) {
         try {
-            // 查询所有员工积分统计(支持日期筛选,已按部门、姓名排序)
+            // 1. 查询所有员工积分统计
             List<WorkKnowledgeBasePoint> employeeList = workKnowledgeBasePointDetailService.findAllEmployeePointStats(queryParam);
 
-            // 构建时间范围标识(如传入了日期筛选)
             String dateRangeSuffix = "";
             if (StringUtils.isNotBlank(queryParam.getBeginDate()) || StringUtils.isNotBlank(queryParam.getEndDate())) {
                 String startDate = StringUtils.isNotBlank(queryParam.getBeginDate()) ? queryParam.getBeginDate() : "不限";
@@ -320,308 +329,288 @@ public class WorkKnowledgeBasePointController extends BaseController {
                 dateRangeSuffix = "(" + startDate + " 至 " + endDate + ")";
             }
 
-            // 定义固定的7个业务指标列(第一行表头)
-            List<String> firstRowHeaders = Arrays.asList(
-                "上传文档数量",
-                "贡献积分额度",
-                "审核文档数量",
-                "审核积分额度",
-                "阅读积分额度",
-                "点赞积分额度",
-                "已兑换积分额度"
-            );
-            
-            // 获取知识库分类树结构(一级分类列表,用于第二行表头细分)
-            java.util.Map<String, List<String>> categoryTreeMap = treeService.getCategoryTreeForExport();
-            List<String> categoryNames = new ArrayList<>(categoryTreeMap.keySet());
-            
-            // 7个业务指标对应的数据字段key(与entity中的getter对应)
-            List<String> columnKeys = Arrays.asList(
-                "uploadCount", "createPoint", "auditCount", "auditPoint",
-                "readPoint", "likePoint", "exchangePoint"
-            );
-            
-            // 构建第二行表头:每个固定列下包含"总量"+各一级分类
-            List<List<String>> secondRowHeaders = new ArrayList<>();
-            for (String columnHeader : firstRowHeaders) {
-                List<String> subHeaders = new ArrayList<>();
-                subHeaders.add("总量");
-                for (String categoryName : categoryNames) {
-                    subHeaders.add(categoryName);
+            // 2. 构建三层表头结构(使用CategoryService已有的方法)
+            Map<String, Object> headerStructure = workKnowledgePointCategoryService.getThreeLevelHeaderStructure();
+            @SuppressWarnings("unchecked")
+            List<String> firstLevelHeaders = (List<String>) headerStructure.get("firstLevelHeaders");
+            @SuppressWarnings("unchecked")
+            List<List<String>> secondLevelHeaders = (List<List<String>>) headerStructure.get("secondLevelHeaders");
+            @SuppressWarnings("unchecked")
+            List<List<List<String>>> thirdLevelHeaders = (List<List<List<String>>>) headerStructure.get("thirdLevelHeaders");
+            @SuppressWarnings("unchecked")
+            List<Map<String, String>> flatColumnKeys = (List<Map<String, String>>) headerStructure.get("columnKeys");
+
+            // 从flatColumnKeys重建dataKeys结构 + 识别L2叶子列
+            List<List<List<String>>> dataKeysL3 = new ArrayList<>();
+            Set<Integer> l2LeafIndices = new HashSet<>(); // 编码为 i*1000+j
+            Set<Integer> directRuleIndices = new HashSet<>(); // 直接规则L2(无子分类的L1下的规则)
+            Set<Integer> subtotalIndices = new HashSet<>(); // 汇总列索引
+            Set<Integer> remainingIndices = new HashSet<>(); // 未兑换积分列索引
+            Map<Integer, Integer> exchangeColumnIndexMap = new HashMap<>(); // L1索引 -> 已兑换积分列的j索引
+            List<List<String>> secondLevelCategoryIds = new ArrayList<>(); // 每个L1对应的L2 categoryId列表
+            List<List<String>> directRuleIds = new ArrayList<>(); // 每个L1对应的直接规则ruleId列表
+            int keyIdx = 0;
+            for (int i = 0; i < firstLevelHeaders.size(); i++) {
+                List<String> l2ForL1 = secondLevelHeaders.get(i);
+                List<List<String>> l3ForL1 = thirdLevelHeaders.get(i);
+                List<List<String>> l3KeysForL1 = new ArrayList<>();
+                List<String> catIdsForL1 = new ArrayList<>();
+                List<String> drIdsForL1 = new ArrayList<>();
+                for (int j = 0; j < l2ForL1.size(); j++) {
+                    List<String> l3ForL2 = l3ForL1.get(j);
+                    List<String> l3Keys = new ArrayList<>();
+                    if (!l3ForL2.isEmpty()) {
+                        for (int k = 0; k < l3ForL2.size(); k++) {
+                            if (keyIdx < flatColumnKeys.size()) {
+                                l3Keys.add(flatColumnKeys.get(keyIdx).get("key")); // "rule_xxx"
+                                keyIdx++;
+                            }
+                        }
+                        catIdsForL1.add(""); // 有L3的L2,不需要categoryId
+                    } else {
+                        // 检查是"汇总"、"兑换"、"L2叶子"还是"直接规则"
+                        if (keyIdx < flatColumnKeys.size()) {
+                            Map<String, String> km = flatColumnKeys.get(keyIdx);
+                            if ("l2leaf".equals(km.get("type"))) {
+                                l2LeafIndices.add(i * 1000 + j);
+                                catIdsForL1.add(km.get("categoryId"));
+                                drIdsForL1.add("");
+                            } else if ("directRule".equals(km.get("type"))) {
+                                directRuleIndices.add(i * 1000 + j);
+                                drIdsForL1.add(km.get("ruleId"));
+                                catIdsForL1.add("");
+                            } else if ("subtotal".equals(km.get("type"))) {
+                                subtotalIndices.add(i * 1000 + j);
+                                catIdsForL1.add("");
+                                drIdsForL1.add("");
+                            } else if ("remaining".equals(km.get("type"))) {
+                                remainingIndices.add(i * 1000 + j);
+                                catIdsForL1.add("");
+                                drIdsForL1.add("");
+                            } else {
+                                // exchange类型,记录该L1组的exchange列索引
+                                exchangeColumnIndexMap.put(i, j);
+                                catIdsForL1.add("");
+                                drIdsForL1.add("");
+                            }
+                            keyIdx++;
+                        } else {
+                            catIdsForL1.add("");
+                            drIdsForL1.add("");
+                        }
+                    }
+                    l3KeysForL1.add(l3Keys);
                 }
-                secondRowHeaders.add(subHeaders);
+                dataKeysL3.add(l3KeysForL1);
+                secondLevelCategoryIds.add(catIdsForL1);
+                directRuleIds.add(drIdsForL1);
             }
-            
-            // 收集所有用户ID
+
+            // 提取L1分类ID列表(从headerStructure中的columnKeys提取exchange对应的firstLevelId)
+            List<String> l1Ids = new ArrayList<>();
+            for (Map<String, String> km : flatColumnKeys) {
+                if ("exchange".equals(km.get("type"))) {
+                    String exKey = km.get("key"); // "exchange_xxx"
+                    l1Ids.add(exKey.substring(exKey.indexOf("_") + 1));
+                }
+            }
+
+            // 3. 收集用户ID
             List<String> userIds = employeeList.stream()
-                .map(emp -> emp.getUserId())
+                .map(WorkKnowledgeBasePoint::getUserId)
                 .filter(StringUtils::isNotBlank)
                 .collect(Collectors.toList());
-            
-            // ========== 分别查询7个业务指标的分类数据 ==========
-                        
-            // 1. 上传文档数量(按一级分类统计用户上传的文档数)
-            java.util.Map<String, java.util.Map<String, Integer>> uploadCountByCategory = new java.util.HashMap<>();
-            if (!userIds.isEmpty() && !categoryNames.isEmpty()) {
-                List<java.util.Map<String, Object>> results = 
-                    workKnowledgeBasePointDetailService.countUploadDocsByTreeNodeIds(categoryNames, userIds, queryParam.getBeginDate(), queryParam.getEndDate());
-                for (java.util.Map<String, Object> result : results) {
-                    String userId = (String) result.get("userId");
-                    String treeName = (String) result.get("treeName");
-                    Integer count = ((Number) result.get("docCount")).intValue();
-                    uploadCountByCategory.computeIfAbsent(userId, k -> new java.util.HashMap<>())
-                        .put(treeName, count);
-                }
-            }
-            
-            // 2. 贡献积分额度(change_type=1,创建积分)
-            java.util.Map<String, java.util.Map<String, Integer>> createPointByCategory = new java.util.HashMap<>();
-            if (!userIds.isEmpty() && !categoryNames.isEmpty()) {
-                List<java.util.Map<String, Object>> results = 
-                    workKnowledgeBasePointDetailService.sumPointsByTreeNodeIdsAndChangeType(categoryNames, userIds, 1, queryParam.getBeginDate(), queryParam.getEndDate());
-                for (java.util.Map<String, Object> result : results) {
-                    String userId = (String) result.get("userId");
-                    String treeName = (String) result.get("treeName");
-                    Integer point = ((Number) result.get("totalPoint")).intValue();
-                    createPointByCategory.computeIfAbsent(userId, k -> new java.util.HashMap<>())
-                        .put(treeName, point);
-                }
-            }
-            
-            // 3. 审核文档数量(按一级分类统计用户审核的文档数)
-            java.util.Map<String, java.util.Map<String, Integer>> auditCountByCategory = new java.util.HashMap<>();
-            if (!userIds.isEmpty() && !categoryNames.isEmpty()) {
-                List<java.util.Map<String, Object>> results = 
-                    workKnowledgeBasePointDetailService.countAuditDocsByTreeNodeIds(categoryNames, userIds, queryParam.getBeginDate(), queryParam.getEndDate());
-                for (java.util.Map<String, Object> result : results) {
-                    String userId = (String) result.get("userId");
-                    String treeName = (String) result.get("treeName");
-                    Integer count = ((Number) result.get("docCount")).intValue();
-                    auditCountByCategory.computeIfAbsent(userId, k -> new java.util.HashMap<>())
-                        .put(treeName, count);
-                }
-            }
-            
-            // 4. 审核积分额度(change_type=2,审核积分)
-            java.util.Map<String, java.util.Map<String, Integer>> auditPointByCategory = new java.util.HashMap<>();
-            if (!userIds.isEmpty() && !categoryNames.isEmpty()) {
-                List<java.util.Map<String, Object>> results = 
-                    workKnowledgeBasePointDetailService.sumPointsByTreeNodeIdsAndChangeType(categoryNames, userIds, 2, queryParam.getBeginDate(), queryParam.getEndDate());
-                for (java.util.Map<String, Object> result : results) {
-                    String userId = (String) result.get("userId");
-                    String treeName = (String) result.get("treeName");
-                    Integer point = ((Number) result.get("totalPoint")).intValue();
-                    auditPointByCategory.computeIfAbsent(userId, k -> new java.util.HashMap<>())
-                        .put(treeName, point);
+
+            // 4a. 按rule分组积分(point>0)
+            java.util.Map<String, java.util.Map<String, Integer>> pointsByRule = new java.util.HashMap<>();
+            if (!userIds.isEmpty()) {
+                List<java.util.Map<String, Object>> ruleResults =
+                    workKnowledgeBasePointDetailService.sumPointsByRuleGrouped(userIds, queryParam.getBeginDate(), queryParam.getEndDate());
+                for (java.util.Map<String, Object> r : ruleResults) {
+                    String uid = (String) r.get("userId");
+                    String rid = String.valueOf(r.get("ruleId"));
+                    int pts = ((Number) r.get("totalPoint")).intValue();
+                    pointsByRule.computeIfAbsent(uid, k -> new java.util.HashMap<>()).put(rid, pts);
                 }
             }
-            
-            // 5. 阅读积分额度(change_type=3,阅读积分)
-            java.util.Map<String, java.util.Map<String, Integer>> readPointByCategory = new java.util.HashMap<>();
-            if (!userIds.isEmpty() && !categoryNames.isEmpty()) {
-                List<java.util.Map<String, Object>> results = 
-                    workKnowledgeBasePointDetailService.sumPointsByTreeNodeIdsAndChangeType(categoryNames, userIds, 3, queryParam.getBeginDate(), queryParam.getEndDate());
-                for (java.util.Map<String, Object> result : results) {
-                    String userId = (String) result.get("userId");
-                    String treeName = (String) result.get("treeName");
-                    Integer point = ((Number) result.get("totalPoint")).intValue();
-                    readPointByCategory.computeIfAbsent(userId, k -> new java.util.HashMap<>())
-                        .put(treeName, point);
+
+            // 4a-extra. 汇总L2叶子分类的积分(用于无L3子列的L2叶子,如"阅读"、"点赞")
+            java.util.Map<String, java.util.Map<String, Integer>> l2PointsByCategory = new java.util.HashMap<>();
+            for (java.util.Map.Entry<String, java.util.Map<String, Integer>> entry : pointsByRule.entrySet()) {
+                String uid = entry.getKey();
+                java.util.Map<String, Integer> userRulePoints = entry.getValue();
+                for (int i = 0; i < secondLevelCategoryIds.size(); i++) {
+                    List<String> catIds = secondLevelCategoryIds.get(i);
+                    for (int j = 0; j < catIds.size(); j++) {
+                        String catId = catIds.get(j);
+                        if (catId.isEmpty()) continue;
+                        // 查询该L2分类下的所有规则ID
+                        List<WorkKnowledgeBasePointRule> catRules = pointRuleDao.findEnabledByCategoryId(catId);
+                        int total = 0;
+                        for (WorkKnowledgeBasePointRule r : catRules) {
+                            total += userRulePoints.getOrDefault(r.getId(), 0);
+                        }
+                        l2PointsByCategory.computeIfAbsent(uid, k -> new java.util.HashMap<>()).put(catId, total);
+                    }
                 }
             }
-            
-            // 6. 点赞积分额度(change_type=4,点赞积分)
-            java.util.Map<String, java.util.Map<String, Integer>> likePointByCategory = new java.util.HashMap<>();
-            if (!userIds.isEmpty() && !categoryNames.isEmpty()) {
-                List<java.util.Map<String, Object>> results = 
-                    workKnowledgeBasePointDetailService.sumPointsByTreeNodeIdsAndChangeType(categoryNames, userIds, 4, queryParam.getBeginDate(), queryParam.getEndDate());
-                for (java.util.Map<String, Object> result : results) {
-                    String userId = (String) result.get("userId");
-                    String treeName = (String) result.get("treeName");
-                    Integer point = ((Number) result.get("totalPoint")).intValue();
-                    likePointByCategory.computeIfAbsent(userId, k -> new java.util.HashMap<>())
-                        .put(treeName, point);
+
+            // 4b. 兑现积分(按L1分类,point<0取绝对值)
+            java.util.Map<String, java.util.Map<String, Integer>> exchangeByL1 = new java.util.HashMap<>();
+            if (!userIds.isEmpty()) {
+                List<java.util.Map<String, Object>> exResults =
+                    workKnowledgeBasePointDetailService.sumExchangeByFirstLevelCategory(userIds, queryParam.getBeginDate(), queryParam.getEndDate());
+                for (java.util.Map<String, Object> r : exResults) {
+                    String uid = (String) r.get("userId");
+                    String lid = String.valueOf(r.get("firstLevelId"));
+                    int pts = ((Number) r.get("totalPoint")).intValue();
+                    exchangeByL1.computeIfAbsent(uid, k -> new java.util.HashMap<>()).put(lid, pts);
                 }
             }
-            
-            // 7. 已兑换积分额度(point<0,兑换积分)
-            java.util.Map<String, java.util.Map<String, Integer>> exchangePointByCategory = new java.util.HashMap<>();
-            if (!userIds.isEmpty() && !categoryNames.isEmpty()) {
-                List<java.util.Map<String, Object>> results = 
-                    workKnowledgeBasePointDetailService.sumExchangePointsByTreeNodeIds(categoryNames, userIds, queryParam.getBeginDate(), queryParam.getEndDate());
-                for (java.util.Map<String, Object> result : results) {
-                    String userId = (String) result.get("userId");
-                    String treeName = (String) result.get("treeName");
-                    Integer point = ((Number) result.get("totalPoint")).intValue();
-                    exchangePointByCategory.computeIfAbsent(userId, k -> new java.util.HashMap<>())
-                        .put(treeName, point);
-                }
+
+            // 4c. 上传文档数量(从employeeList获取)
+            java.util.Map<String, Integer> uploadCountMap = new java.util.HashMap<>();
+            for (WorkKnowledgeBasePoint emp : employeeList) {
+                uploadCountMap.put(emp.getUserId(), emp.getUploadCount() != null ? emp.getUploadCount() : 0);
             }
 
-            // 业务指标字段与entity getter的映射
-            java.util.Map<String, String> columnGetterMap = new java.util.LinkedHashMap<>();
-            columnGetterMap.put("uploadCount", "getUploadCount");
-            columnGetterMap.put("createPoint", "getCreatePoint");
-            columnGetterMap.put("auditCount", "getAuditCount");
-            columnGetterMap.put("auditPoint", "getAuditPoint");
-            columnGetterMap.put("readPoint", "getReadPoint");
-            columnGetterMap.put("likePoint", "getLikePoint");
-            columnGetterMap.put("exchangePoint", "getExchangePoint");
+            // 5. 构建前缀列
+            List<String> prefixHeadersAll = Arrays.asList("部门名称|officeName", "用户姓名|userName");
+            List<String> prefixHeadersCompany = Arrays.asList("名称|label");
+            List<String> prefixHeadersDept = Arrays.asList("部门名称|officeName");
+
+            // === 辅助函数:构建数据行 ===
+            java.util.function.Function<List<WorkKnowledgeBasePoint>, Map<String, Object>> rowBuilder = (emps) -> {
+                Map<String, Object> row = new LinkedHashMap<>();
+                // 上传文档数量
+                int totalUpload = emps.stream().mapToInt(e -> e.getUploadCount() != null ? e.getUploadCount() : 0).sum();
+                row.put("uploadCount", totalUpload);
+
+                // 遍历每个L1组
+                for (int i = 0; i < firstLevelHeaders.size(); i++) {
+                    List<String> l2ForL1 = secondLevelHeaders.get(i);
+                    List<List<String>> l3ForL1 = thirdLevelHeaders.get(i);
+                    String l1Id = (i < l1Ids.size()) ? l1Ids.get(i) : "";
+
+                    for (int j = 0; j < l2ForL1.size(); j++) {
+                        List<String> l3ForL2 = l3ForL1.get(j);
+                        if (l3ForL2.isEmpty()) {
+                            if (directRuleIndices.contains(i * 1000 + j)) {
+                                // 直接规则L2(如"基础积分"下的规则),直接从pointsByRule取数据
+                                String ruleId = (i < directRuleIds.size() && j < directRuleIds.get(i).size())
+                                    ? directRuleIds.get(i).get(j) : "";
+                                int drVal = emps.stream().mapToInt(e -> {
+                                    java.util.Map<String, Integer> m = pointsByRule.get(e.getUserId());
+                                    return m != null && m.containsKey(ruleId) ? m.get(ruleId) : 0;
+                                }).sum();
+                                row.put("exchange_" + i + "_" + j, drVal);
+                            } else if (l2LeafIndices.contains(i * 1000 + j)) {
+                                // L2叶子列(如"阅读"、"点赞"),使用L2级汇总数据
+                                String catId = (i < secondLevelCategoryIds.size() && j < secondLevelCategoryIds.get(i).size())
+                                    ? secondLevelCategoryIds.get(i).get(j) : "";
+                                int leafVal = emps.stream().mapToInt(e -> {
+                                    java.util.Map<String, Integer> m = l2PointsByCategory.get(e.getUserId());
+                                    return m != null && m.containsKey(catId) ? m.get(catId) : 0;
+                                }).sum();
+                                row.put("exchange_" + i + "_" + j, leafVal);
+                            } else if (subtotalIndices.contains(i * 1000 + j)) {
+                                // 汇总列(占位符,后面会覆盖)
+                                row.put("subtotal_" + i, 0);
+                            } else if (remainingIndices.contains(i * 1000 + j)) {
+                                // 未兑换积分列(占位符,后面计算 subtotal - exchange)
+                                row.put("remaining_" + i, 0);
+                            } else {
+                                // 已兑换积分列
+                                int exVal = emps.stream().mapToInt(e -> {
+                                    java.util.Map<String, Integer> m = exchangeByL1.get(e.getUserId());
+                                    return m != null && m.containsKey(l1Id) ? m.get(l1Id) : 0;
+                                }).sum();
+                                row.put("exchange_" + i + "_" + j, exVal);
+                            }
+                        } else {
+                            for (int k = 0; k < l3ForL2.size(); k++) {
+                                String ruleKey = (i < dataKeysL3.size() && j < dataKeysL3.get(i).size()
+                                    && k < dataKeysL3.get(i).get(j).size())
+                                    ? dataKeysL3.get(i).get(j).get(k) : "";
+                                String ruleId = ruleKey.startsWith("rule_") ? ruleKey.substring(5) : "";
+                                int ruleVal = emps.stream().mapToInt(e -> {
+                                    java.util.Map<String, Integer> m = pointsByRule.get(e.getUserId());
+                                    return m != null && m.containsKey(ruleId) ? m.get(ruleId) : 0;
+                                }).sum();
+                                row.put("rule_" + i + "_" + j + "_" + k, ruleVal);
+                            }
+                        }
+                    }
+                    // 小计 = 所有规则列之和 + L2叶子列之和 + 直接规则列之和(不含汇总、已兑换、未兑换)
+                    int subtotal = 0;
+                    for (int j = 0; j < l2ForL1.size(); j++) {
+                        List<String> l3ForL2 = l3ForL1.get(j);
+                        if (subtotalIndices.contains(i * 1000 + j) || remainingIndices.contains(i * 1000 + j)) {
+                            // 跳过汇总列和未兑换积分列本身
+                            continue;
+                        } else if (directRuleIndices.contains(i * 1000 + j) || l2LeafIndices.contains(i * 1000 + j)) {
+                            // 直接规则L2或L2叶子列,直接取值
+                            Object v = row.get("exchange_" + i + "_" + j);
+                            if (v instanceof Number) subtotal += ((Number) v).intValue();
+                        } else if (!l3ForL2.isEmpty()) {
+                            // 有L3的规则列
+                            for (int k = 0; k < l3ForL2.size(); k++) {
+                                Object v = row.get("rule_" + i + "_" + j + "_" + k);
+                                if (v instanceof Number) subtotal += ((Number) v).intValue();
+                            }
+                        }
+                        // 注意:已兑换积分列不计入小计
+                    }
+                    row.put("subtotal_" + i, subtotal);
+                    // 剩余积分 = 积分汇总 - 已兑换积分
+                    Integer exColIdx = exchangeColumnIndexMap.get(i);
+                    int exVal = 0;
+                    if (exColIdx != null) {
+                        Object v = row.get("exchange_" + i + "_" + exColIdx);
+                        if (v instanceof Number) {
+                            exVal = ((Number) v).intValue();
+                        }
+                    }
+                    row.put("remaining_" + i, subtotal - exVal);
+                }
+                return row;
+            };
 
             ExportKnowledgeBaseExcel excel = new ExportKnowledgeBaseExcel();
 
-            // ========== 1. 公司级汇总(双表头)==========
+            // ========== 1. 公司级汇总 ==========
             List<Map<String, Object>> companyData = new ArrayList<>();
-            Map<String, Object> companyRow = new LinkedHashMap<>();
+            Map<String, Object> companyRow = rowBuilder.apply(employeeList);
             companyRow.put("label", "公司汇总");
-            
-            for (int i = 0; i < firstRowHeaders.size(); i++) {
-                String columnKey = columnKeys.get(i);
-                List<String> subHeaders = secondRowHeaders.get(i);
-                
-                java.util.Map<String, java.util.Map<String, Integer>> dataSource;
-                switch (i) {
-                    case 0: dataSource = uploadCountByCategory; break;
-                    case 1: dataSource = createPointByCategory; break;
-                    case 2: dataSource = auditCountByCategory; break;
-                    case 3: dataSource = auditPointByCategory; break;
-                    case 4: dataSource = readPointByCategory; break;
-                    case 5: dataSource = likePointByCategory; break;
-                    case 6: dataSource = exchangePointByCategory; break;
-                    default: dataSource = new java.util.HashMap<>();
-                }
-                
-                for (String subHeader : subHeaders) {
-                    String dataKey = columnKey + "_" + subHeader;
-                    if ("总量".equals(subHeader)) {
-                        // 总量从 employeeList 累加
-                        int total = employeeList.stream().mapToInt(emp -> {
-                            try {
-                                java.lang.reflect.Method m = emp.getClass().getMethod(columnGetterMap.get(columnKey));
-                                Number val = (Number) m.invoke(emp);
-                                return val != null ? val.intValue() : 0;
-                            } catch (Exception e) { return 0; }
-                        }).sum();
-                        companyRow.put(dataKey, total);
-                    } else {
-                        // 分类数据从分类查询结果获取
-                        int categorySum = employeeList.stream()
-                            .mapToInt(emp -> {
-                                java.util.Map<String, Integer> points = dataSource.get(emp.getUserId());
-                                return points != null && points.containsKey(subHeader) ? points.get(subHeader) : 0;
-                            }).sum();
-                        companyRow.put(dataKey, categorySum);
-                    }
-                }
-            }
             companyData.add(companyRow);
-            
-            excel.addDoubleHeaderSheet("公司级", "公司级积分统计汇总" + dateRangeSuffix,
-                Arrays.asList("名称|label"), firstRowHeaders, secondRowHeaders, columnKeys, companyData);
+            excel.addTripleHeaderSheet("公司级", "公司级积分统计汇总" + dateRangeSuffix,
+                prefixHeadersCompany, firstLevelHeaders, secondLevelHeaders, thirdLevelHeaders, companyData);
 
-            // ========== 2. 部门级汇总(双表头)==========
-            // 按部门分组
-            java.util.Map<String, List<WorkKnowledgeBasePoint>> deptMap = new LinkedHashMap<>();
+            // ========== 2. 部门级汇总 ==========
+            Map<String, List<WorkKnowledgeBasePoint>> deptMap = new LinkedHashMap<>();
             for (WorkKnowledgeBasePoint emp : employeeList) {
                 String dept = emp.getOfficeName() != null ? emp.getOfficeName() : "未分配部门";
                 deptMap.computeIfAbsent(dept, k -> new ArrayList<>()).add(emp);
             }
-            
             List<Map<String, Object>> deptData = new ArrayList<>();
-            for (java.util.Map.Entry<String, List<WorkKnowledgeBasePoint>> entry : deptMap.entrySet()) {
-                String deptName = entry.getKey();
-                List<WorkKnowledgeBasePoint> deptEmps = entry.getValue();
-                
-                Map<String, Object> row = new LinkedHashMap<>();
-                row.put("officeName", deptName);
-                
-                for (int i = 0; i < firstRowHeaders.size(); i++) {
-                    String columnKey = columnKeys.get(i);
-                    List<String> subHeaders = secondRowHeaders.get(i);
-                    
-                    java.util.Map<String, java.util.Map<String, Integer>> dataSource;
-                    switch (i) {
-                        case 0: dataSource = uploadCountByCategory; break;
-                        case 1: dataSource = createPointByCategory; break;
-                        case 2: dataSource = auditCountByCategory; break;
-                        case 3: dataSource = auditPointByCategory; break;
-                        case 4: dataSource = readPointByCategory; break;
-                        case 5: dataSource = likePointByCategory; break;
-                        case 6: dataSource = exchangePointByCategory; break;
-                        default: dataSource = new java.util.HashMap<>();
-                    }
-                    
-                    for (String subHeader : subHeaders) {
-                        String dataKey = columnKey + "_" + subHeader;
-                        if ("总量".equals(subHeader)) {
-                            int total = deptEmps.stream().mapToInt(emp -> {
-                                try {
-                                    java.lang.reflect.Method m = emp.getClass().getMethod(columnGetterMap.get(columnKey));
-                                    Number val = (Number) m.invoke(emp);
-                                    return val != null ? val.intValue() : 0;
-                                } catch (Exception e) { return 0; }
-                            }).sum();
-                            row.put(dataKey, total);
-                        } else {
-                            int categorySum = deptEmps.stream()
-                                .mapToInt(emp -> {
-                                    java.util.Map<String, Integer> points = dataSource.get(emp.getUserId());
-                                    return points != null && points.containsKey(subHeader) ? points.get(subHeader) : 0;
-                                }).sum();
-                            row.put(dataKey, categorySum);
-                        }
-                    }
-                }
+            for (Map.Entry<String, List<WorkKnowledgeBasePoint>> entry : deptMap.entrySet()) {
+                Map<String, Object> row = rowBuilder.apply(entry.getValue());
+                row.put("officeName", entry.getKey());
                 deptData.add(row);
             }
-            
-            excel.addDoubleHeaderSheet("部门级", "部门级积分统计汇总" + dateRangeSuffix,
-                Arrays.asList("部门名称|officeName"), firstRowHeaders, secondRowHeaders, columnKeys, deptData);
+            excel.addTripleHeaderSheet("部门级", "部门级积分统计汇总" + dateRangeSuffix,
+                prefixHeadersDept, firstLevelHeaders, secondLevelHeaders, thirdLevelHeaders, deptData);
 
-            // ========== 3. 员工级明细(双表头)==========
+            // ========== 3. 员工级明细 ==========
             List<Map<String, Object>> empData = new ArrayList<>();
             for (WorkKnowledgeBasePoint emp : employeeList) {
-                Map<String, Object> row = new LinkedHashMap<>();
+                Map<String, Object> row = rowBuilder.apply(Arrays.asList(emp));
                 row.put("officeName", emp.getOfficeName() != null ? emp.getOfficeName() : "未分配部门");
                 row.put("userName", emp.getUserName() != null ? emp.getUserName() : "");
-                
-                for (int i = 0; i < firstRowHeaders.size(); i++) {
-                    String columnKey = columnKeys.get(i);
-                    List<String> subHeaders = secondRowHeaders.get(i);
-                    
-                    java.util.Map<String, java.util.Map<String, Integer>> dataSource;
-                    switch (i) {
-                        case 0: dataSource = uploadCountByCategory; break;
-                        case 1: dataSource = createPointByCategory; break;
-                        case 2: dataSource = auditCountByCategory; break;
-                        case 3: dataSource = auditPointByCategory; break;
-                        case 4: dataSource = readPointByCategory; break;
-                        case 5: dataSource = likePointByCategory; break;
-                        case 6: dataSource = exchangePointByCategory; break;
-                        default: dataSource = new java.util.HashMap<>();
-                    }
-                    
-                    for (String subHeader : subHeaders) {
-                        String dataKey = columnKey + "_" + subHeader;
-                        if ("总量".equals(subHeader)) {
-                            try {
-                                java.lang.reflect.Method m = emp.getClass().getMethod(columnGetterMap.get(columnKey));
-                                Number val = (Number) m.invoke(emp);
-                                row.put(dataKey, val != null ? val.intValue() : 0);
-                            } catch (Exception e) {
-                                row.put(dataKey, 0);
-                            }
-                        } else {
-                            java.util.Map<String, Integer> points = dataSource.get(emp.getUserId());
-                            row.put(dataKey, points != null && points.containsKey(subHeader) ? points.get(subHeader) : 0);
-                        }
-                    }
-                }
                 empData.add(row);
             }
-            
-            excel.addDoubleHeaderSheet("员工级", "员工级积分统计明细" + dateRangeSuffix,
-                Arrays.asList("部门名称|officeName", "用户姓名|userName"), firstRowHeaders, secondRowHeaders, columnKeys, empData);
+            excel.addTripleHeaderSheet("员工级", "员工级积分统计明细" + dateRangeSuffix,
+                prefixHeadersAll, firstLevelHeaders, secondLevelHeaders, thirdLevelHeaders, empData);
 
             String fileName = "知识库统计表_" + DateUtils.getDate("yyyyMMddHHmmss") + ".xlsx";
             excel.write(response, fileName);

+ 65 - 1
src/main/resources/mappings/modules/WorkKnowledgeBase/WorkKnowledgeBasePointDetailDao.xml

@@ -518,7 +518,8 @@
         ) ep ON ep.user_id = u.id
         WHERE u.del_flag = '0'
           AND (
-              COALESCE(cp.create_point, 0) > 0
+              COALESCE(uc.upload_count, 0) > 0
+              OR COALESCE(cp.create_point, 0) > 0
               OR COALESCE(ap.audit_point, 0) > 0
               OR COALESCE(rp.read_point, 0) > 0
               OR COALESCE(lp.like_point, 0) > 0
@@ -671,4 +672,67 @@
         GROUP BY d.user_id, p.tree_name
     </select>
 
+    <!-- 按rule_id分组统计积分总和(用于三层表头导出) -->
+    <select id="sumPointsByRuleGrouped" resultType="java.util.HashMap">
+        SELECT
+            d.user_id AS userId,
+            c.id AS categoryId,
+            c.parent_id AS parentId,
+            d.rule_id AS ruleId,
+            SUM(d.point) AS totalPoint
+        FROM work_knowledge_base_point_detail d
+        INNER JOIN work_knowledge_point_category c ON c.id = d.category_id AND c.del_flag = '0'
+        WHERE d.del_flag = '0'
+          AND d.point &gt; 0
+          AND d.rule_id IS NOT NULL AND d.rule_id != ''
+          AND d.user_id IN
+          <foreach collection="userIds" item="userId" open="(" separator="," close=")">
+              #{userId}
+          </foreach>
+          <if test="beginDate != null and beginDate != ''">
+              AND d.create_date &gt;= TIMESTAMP(CONCAT(#{beginDate}, ' 00:00:00'))
+          </if>
+          <if test="endDate != null and endDate != ''">
+              AND d.create_date &lt;= TIMESTAMP(CONCAT(#{endDate}, ' 23:59:59'))
+          </if>
+        GROUP BY d.user_id, c.id, c.parent_id, d.rule_id
+    </select>
+
+    <!-- 按L1分类统计兑换积分绝对值总和(用于三层表头的兑现列) -->
+    <select id="sumExchangeByFirstLevelCategory" resultType="java.util.HashMap">
+        SELECT
+            d.user_id AS userId,
+            CASE 
+                WHEN c.parent_id = '0' THEN c.id
+                ELSE c.parent_id
+            END AS firstLevelId,
+            ABS(SUM(d.point)) AS totalPoint
+        FROM work_knowledge_base_point_detail d
+        INNER JOIN work_knowledge_point_category c ON c.id = d.category_id AND c.del_flag = '0'
+        WHERE d.del_flag = '0'
+          AND d.point &lt; 0
+          AND (
+              -- 情况1:category本身是L1分类(parent_id='0')
+              c.parent_id = '0'
+              OR
+              -- 情况2:category有父分类,且父分类是L1(parent_id='0')
+              EXISTS (SELECT 1 FROM work_knowledge_point_category p WHERE p.id = c.parent_id AND p.parent_id = '0' AND p.del_flag = '0')
+          )
+          AND d.user_id IN
+          <foreach collection="userIds" item="userId" open="(" separator="," close=")">
+              #{userId}
+          </foreach>
+          <if test="beginDate != null and beginDate != ''">
+              AND d.create_date &gt;= TIMESTAMP(CONCAT(#{beginDate}, ' 00:00:00'))
+          </if>
+          <if test="endDate != null and endDate != ''">
+              AND d.create_date &lt;= TIMESTAMP(CONCAT(#{endDate}, ' 23:59:59'))
+          </if>
+        GROUP BY d.user_id, 
+            CASE 
+                WHEN c.parent_id = '0' THEN c.id
+                ELSE c.parent_id
+            END
+    </select>
+
 </mapper>

+ 10 - 0
src/main/resources/mappings/modules/WorkKnowledgeBase/WorkKnowledgeBasePointRuleDao.xml

@@ -80,6 +80,16 @@
         ORDER BY a.update_date DESC
     </select>
 
+    <!-- 根据分类ID精确匹配查询启用的规则,按rule_type升序排列(用于三层表头导出) -->
+    <select id="findEnabledByCategoryId" resultType="com.jeeplus.modules.WorkKnowledgeBase.entity.WorkKnowledgeBasePointRule">
+        SELECT <include refid="pointRuleColumns"/>
+        FROM work_knowledge_base_point_rule a
+        WHERE a.del_flag = '0'
+          AND a.status = '1'
+          AND a.category_id = #{categoryId}
+        ORDER BY a.rule_type ASC
+    </select>
+
     <select id="findUniqueByProperty" resultType="com.jeeplus.modules.WorkKnowledgeBase.entity.WorkKnowledgeBasePointRule">
         SELECT <include refid="pointRuleColumns"/>
         FROM work_knowledge_base_point_rule a

+ 21 - 0
src/main/resources/mappings/modules/WorkKnowledgeBase/WorkKnowledgePointCategoryDao.xml

@@ -52,6 +52,27 @@
         ORDER BY a.sort ASC, a.create_date ASC
     </select>
 
+    <!-- 查询第一层分类(parent_id=0,启用状态,排除id=9) -->
+    <select id="findFirstLevelList" resultType="com.jeeplus.modules.WorkKnowledgeBase.entity.WorkKnowledgePointCategory">
+        SELECT <include refid="categoryColumns"/>
+        FROM work_knowledge_point_category a
+        WHERE a.del_flag = 0
+          AND a.parent_id = '0'
+          AND a.status = '1'
+          AND a.id != '9'
+        ORDER BY a.sort ASC, a.create_date ASC
+    </select>
+
+    <!-- 查询指定父节点下的子分类(启用状态) -->
+    <select id="findChildrenByParentId" resultType="com.jeeplus.modules.WorkKnowledgeBase.entity.WorkKnowledgePointCategory">
+        SELECT <include refid="categoryColumns"/>
+        FROM work_knowledge_point_category a
+        WHERE a.del_flag = 0
+          AND a.parent_id = #{parentId}
+          AND a.status = '1'
+        ORDER BY a.sort ASC, a.create_date ASC
+    </select>
+
     <select id="findByParentIdsLike" resultType="com.jeeplus.modules.WorkKnowledgeBase.entity.WorkKnowledgePointCategory">
         SELECT <include refid="categoryColumns"/>
         FROM work_knowledge_point_category a

+ 2 - 2
src/main/webapp/webpage/modules/WorkKnowledgeBase/workKnowledgeBasePointList.jsp

@@ -288,14 +288,14 @@
                     
                 downForm.submit();
                     
-                // 兗底:10秒后恢复
+                // 兗底:2秒后恢复
                 setTimeout(function() {
                     isExporting = false;
                     top.layer.close(loadingIndex);
                     try { top.layer.close(index); } catch(e) {}
                     try { downForm.remove(); downIframe.remove(); } catch(e) {}
                     search();
-                }, 10000);
+                }, 2000);
             },
             btn2: function(index) {
                 top.layer.close(index);

+ 2 - 2
src/main/webapp/webpage/modules/WorkKnowledgeBase/workKnowledgeBaseShareDetail.jsp

@@ -387,10 +387,10 @@
 
 
 
-        <!-- 审核意见区域(审核中和未审核状态下,非创建人可见) -->
+        <!-- 审核意见区域(审核中和未审核状态下,非创建人可见,且非只读模式) -->
         <c:set var="auditStatusInt" value="${entity.auditStatus != null ? entity.auditStatus : 0}"/>
         <c:set var="isCreator" value="${entity.createBy != null && entity.createBy.id == fns:getUser().id}"/>
-        <c:if test="${(auditStatusInt == 1 || auditStatusInt == 2) && !isCreator}">
+        <c:if test="${(auditStatusInt == 1 || auditStatusInt == 2) && !isCreator && !readOnly}">
             <div class="form-group layui-row">
                 <div class="form-group-label"><h2>审核意见</h2></div>
                 <!-- 如果是技术总结或培训心得,显示评分输入框 -->

+ 1 - 1
src/main/webapp/webpage/modules/WorkKnowledgeBase/workKnowledgeBaseShareList.jsp

@@ -88,7 +88,7 @@
                 area: ['80%', '80%'],
                 title: '文件详情',
                 maxmin: true,
-                content: '${ctx}/workKnowledgeBase/share/detail?id=' + id + '&treeNodeId=${treeNodeId}&rootId=${rootId}',
+                content: '${ctx}/workKnowledgeBase/share/detail?readOnly=true&id=' + id + '&treeNodeId=${treeNodeId}&rootId=${rootId}',
                 btn: ['关闭'],
                 btn1: function(index) { top.layer.close(index); }
             });