diff --git a/src/main/java/io/v/nutz/databoard/controller/CulturalSportsBoardController.java b/src/main/java/io/v/nutz/databoard/controller/CulturalSportsBoardController.java index b52be66..a5afdf6 100644 --- a/src/main/java/io/v/nutz/databoard/controller/CulturalSportsBoardController.java +++ b/src/main/java/io/v/nutz/databoard/controller/CulturalSportsBoardController.java @@ -46,23 +46,55 @@ public class CulturalSportsBoardController { public Object data(@Param("year") Integer year, @Param("period") String period, @Param("source") String source, @Param("unitId") String unitId, @Param("activityId") String activityId) { + String error = validateFilters(year, period, source, unitId, activityId); + if (error != null) return Result.error(error); + return Result.success().addData(culturalSportsBoardService.statistics(year, period, source, unitId, activityId)); + } + + /** + * year/period/source/unitId/activityId 与 data 接口一致,返回活动报告.docx 二进制文件。 + * 参数错误返回 HTTP 400 JSON;报告生成成功后才设置下载响应头,避免将错误页当作 Word 下载。 + */ + @At + @POST + @Ok("void") + public void exportWord(@Param("year") Integer year, @Param("period") String period, + @Param("source") String source, @Param("unitId") String unitId, + @Param("activityId") String activityId, javax.servlet.http.HttpServletResponse response) throws java.io.IOException { + String error = validateFilters(year, period, source, unitId, activityId); + if (error != null) { + response.setStatus(400); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(org.nutz.json.Json.toJson(Result.error(error))); + return; + } + byte[] document = culturalSportsBoardService.exportReport(year, period, source, unitId, activityId); + response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + response.setHeader("Content-Disposition", "attachment; filename=activity-report.docx; filename*=UTF-8''" + + java.net.URLEncoder.encode("活动报告.docx", "UTF-8")); + response.setContentLength(document.length); + response.getOutputStream().write(document); + } + + /** 查询与导出共用基础参数校验;返回错误提示,合法时返回 null,不在控制器内处理统计业务。 */ + private String validateFilters(Integer year, String period, String source, String unitId, String activityId) { if (year == null || year < 1900 || year > LocalDate.now().getYear()) { - return Result.error("请选择有效年度,不能查询未来年度"); + return ("请选择有效年度,不能查询未来年度"); } if (!Arrays.asList("all", "first", "second").contains(period) || !Arrays.asList("all", "culture", "sports", "training", "family").contains(source)) { - return Result.error("统计周期或活动来源不正确"); + return ("统计周期或活动来源不正确"); } if (unitId != null && unitId.length() > 64) { - return Result.error("单位参数不正确"); + return ("单位参数不正确"); } // 活动键来自 options;限制长度及来源前缀,防止跨来源串用筛选值。 if (activityId != null && !activityId.isEmpty()) { String prefix = "family".equals(source) ? "family:" : "training".equals(source) ? "training:" : "activity:"; if (activityId.length() > 100 || (!"all".equals(source) && !activityId.startsWith(prefix))) { - return Result.error("活动参数与来源不匹配,请重新选择活动"); + return ("活动参数与来源不匹配,请重新选择活动"); } } - return Result.success().addData(culturalSportsBoardService.statistics(year, period, source, unitId, activityId)); + return null; } } diff --git a/src/main/java/io/v/nutz/databoard/service/CulturalSportsBoardService.java b/src/main/java/io/v/nutz/databoard/service/CulturalSportsBoardService.java index e47459c..86d3d16 100644 --- a/src/main/java/io/v/nutz/databoard/service/CulturalSportsBoardService.java +++ b/src/main/java/io/v/nutz/databoard/service/CulturalSportsBoardService.java @@ -21,7 +21,14 @@ public interface CulturalSportsBoardService { * composition/ages/titles/genders 各维度人数与比例,notes 为统计口径说明。 * 参与率为 0—100 的百分数;分母为零时 rate 为 null。 * 分母为活动名单(缺失时用当前在职人员)与有效报名人员并集,按人员 ID 跨活动去重。 + * activities 为当前周期全部活动的名称、来源、开始日期、参与人数、范围人数与参与率; * fallbackActivities 列出采用默认在职范围的活动,notes 说明统计口径。 */ NutMap statistics(int year, String period, String source, String unitId, String activityId); + /** + * 按与 statistics 相同的五项筛选条件重新统计,生成包含分析、图表及全部明细的 Word。 + * @return DOCX 文件字节;不接受客户端传入的统计数字,零分母在报告中显示为“—”。 + */ + byte[] exportReport(int year, String period, String source, String unitId, String activityId) throws java.io.IOException; } + diff --git a/src/main/java/io/v/nutz/databoard/service/impl/CulturalSportsBoardServiceImpl.java b/src/main/java/io/v/nutz/databoard/service/impl/CulturalSportsBoardServiceImpl.java index 7b803e9..e6304f4 100644 --- a/src/main/java/io/v/nutz/databoard/service/impl/CulturalSportsBoardServiceImpl.java +++ b/src/main/java/io/v/nutz/databoard/service/impl/CulturalSportsBoardServiceImpl.java @@ -9,6 +9,19 @@ import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.util.NutMap; +import org.apache.poi.xwpf.usermodel.*; +import org.apache.poi.util.Units; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.*; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import javax.imageio.ImageIO; import java.time.LocalDate; import java.time.Period; import java.time.YearMonth; @@ -228,6 +241,25 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic } // 范围外有效报名补入分母;跨活动取并集,每名人员只计一次。 population.addAll(selectedPeople); + // 报告活动明细复用本次已查询的名单与报名集合;单活动分母也必须补入范围外报名人员。 + List activityRows = new ArrayList<>(); + for (NutMap event : selectedEvents) { + Set roster = scopes.get(text(event.getString("scope_id"))); + if (roster == null || roster.isEmpty()) roster = currentStaff; + Set eventPopulation = new HashSet<>(); + for (String id : roster) { + NutMap person = people.get(id); + if (selectedUnit.isEmpty() || (person != null && selectedUnit.equals(unitKey(person)))) eventPopulation.add(id); + } + Set joined = eventPeople.getOrDefault(event.getString("event_key"), java.util.Collections.emptySet()); + eventPopulation.addAll(joined); + activityRows.add(row(event.getString("event_key"), event.getString("name")) + .setv("source", event.getString("source")).setv("date", parseDate(event.getString("starts_at")).toString()) + .setv("participants", joined.size()).setv("total", eventPopulation.size()) + .setv("rate", rate(joined.size(), eventPopulation.size()))); + } + activityRows.sort(Comparator.comparing((NutMap item) -> item.getString("date")).thenComparing(item -> item.getString("id"))); + boolean rateAvailable = !population.isEmpty(); Set displayedPeople = population; @@ -317,11 +349,324 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic .setv("composition", rows(composition, selectedPeople.size(), rateAvailable)) .setv("ages", rows(ages, selectedPeople.size(), rateAvailable)).setv("titles", titleRows) .setv("genders", rows(genders, selectedPeople.size(), rateAvailable)).setv("notes", notes) - .setv("fallbackActivities", fallbackActivities) + .setv("activities", activityRows).setv("fallbackActivities", fallbackActivities) .setv("periodName", periodName).setv("scopeLabel", SCOPE_LABEL) .setv("asOf", cutoff.toString()).setv("year", year); } + /** 按已校验的筛选条件从服务端重新统计;报告与看板共用聚合结果,文件仅在内存中生成。 */ + @Override + public byte[] exportReport(int year, String period, String source, String unitId, String activityId) throws IOException { + NutMap board = statistics(year, period, source, unitId, activityId); + String unitName = "全部单位"; + if (!text(unitId).isEmpty()) { + if (UNKNOWN.equals(unitId)) unitName = "未归属单位"; + else { + List result = query(Sqls.create("SELECT name FROM sys_unit WHERE id=@id").setParam("id", unitId)); + unitName = result.isEmpty() ? "所选单位(档案已不存在)" : result.get(0).getString("name"); + } + } + String activityName = "全部活动"; + if (!text(activityId).isEmpty()) { + activityName = reportRows(board, "activities").stream().filter(item -> activityId.equals(item.getString("id"))) + .map(item -> item.getString("name")).findFirst().orElse("所选活动(当前条件下无已开展数据)"); + } + try (XWPFDocument document = new XWPFDocument(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { + CTSectPr section = document.getDocument().getBody().addNewSectPr(); + CTPageSz page = section.addNewPgSz(); + page.setW(BigInteger.valueOf(11906)); page.setH(BigInteger.valueOf(16838)); + CTPageMar margins = section.addNewPgMar(); + margins.setTop(BigInteger.valueOf(1134)); margins.setBottom(BigInteger.valueOf(1134)); + margins.setLeft(BigInteger.valueOf(1134)); margins.setRight(BigInteger.valueOf(1134)); + XWPFStyles styles = document.createStyles(); + for (String id : Arrays.asList("Title", "Heading1")) { + CTStyle style = CTStyle.Factory.newInstance(); style.setStyleId(id); style.setType(STStyleType.PARAGRAPH); + style.addNewName().setVal(id); styles.addStyle(new XWPFStyle(style)); + } + XWPFParagraph title = reportParagraph(document, "活动报告", 22, true); + title.setStyle("Title"); title.setAlignment(ParagraphAlignment.CENTER); + reportParagraph(document, year + "年" + board.getString("periodName") + "文体活动参与情况分析", 12, false).setAlignment(ParagraphAlignment.CENTER); + reportParagraph(document, "活动来源:" + sourceName(source) + " 单位:" + unitName, 10, false); + reportParagraph(document, "活动名称:" + activityName + " 统计截至:" + board.getString("asOf"), 10, false); + reportParagraph(document, "生成时间:" + java.time.LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")), 10, false); + NutMap summary = (NutMap) board.get("summary"); + int count = summary.getInt("activityCount"), visits = summary.getInt("visitCount"), joined = summary.getInt("participantCount"); + reportHeading(document, "总体概况", false); + String overview = count == 0 ? "当前查询条件下暂无已开展活动,参与数据为空,暂不进行群体排名和趋势分析。" + : "本期共开展 " + count + " 场活动,累计参与 " + visits + " 人次,跨活动去重后覆盖 " + joined + + " 人,统计范围共 " + summary.getInt("populationCount") + " 人,整体参与率为 " + reportPercent(summary.get("rate")) + "。"; + if (joined > 0) overview += " 参与人员人均参与 " + String.format(java.util.Locale.ROOT, "%.2f", visits * 1.0 / joined) + " 场活动。"; + reportParagraph(document, overview, 11, false); + reportTable(document, Arrays.asList("指标", "数值"), Arrays.asList( + Arrays.asList("活动总数", count + " 场"), Arrays.asList("参与人次", visits + " 人次"), + Arrays.asList("去重参与人数", joined + " 人"), Arrays.asList("统计范围人数", summary.getInt("populationCount") + " 人"), + Arrays.asList("整体参与率", reportPercent(summary.get("rate"))))); + reportParagraph(document, "参与率按所选活动范围与有效报名人员的并集计算;范围名单缺失的活动使用当前在职教职工兜底。参与口径为有效报名或登记,不等同于实际到场。", 10, false); + // 无活动时仍导出查询条件、零值指标和口径说明,避免生成多页没有内容的章节。 + if (count == 0) { + reportParagraph(document, "当前条件下没有可用于人员构成、年龄、性别、职称及单位对比的数据。可调整年度、周期、来源、活动或单位后重新查询。", 11, false); + document.write(output); + return output.toByteArray(); + } + if (count > 0) { + reportHeading(document, "月度参与情况", false); + List months = reportRows(board, "monthly"); + reportBars(document, months, "value", "月度参与人次"); + NutMap peak = months.stream().filter(item -> item.get("value") != null) + .max(Comparator.comparingInt(item -> item.getInt("value"))).orElse(null); + if (peak != null && peak.getInt("value") > 0) reportParagraph(document, + "月度参与人次最高为 " + peak.getInt("value") + " 人次,出现在 " + months.stream() + .filter(item -> item.get("value") != null && item.getInt("value") == peak.getInt("value")) + .map(item -> item.getString("name")).collect(Collectors.joining("、")) + "。月份按活动开始时间归属,未来月份标记为未开始。", 11, false); + else reportParagraph(document, "本期已开展活动暂无有效报名或登记人员。", 11, false); + reportTable(document, Arrays.asList("月份", "参与人次"), months.stream().map(item -> Arrays.asList(item.getString("name"), + item.get("value") == null ? "未开始" : String.valueOf(item.getInt("value")))).collect(Collectors.toList())); + } + reportHeading(document, "活动明细", true); + reportParagraph(document, "以下为当前查询范围全部活动。单活动参与人数跨活动相加为参与人次,不能作为整体去重参与人数;单活动范围人数也不能直接相加作为整体分母。", 10, false); + reportTable(document, Arrays.asList("活动名称", "来源", "开始日期", "参与人数", "范围人数", "参与率"), + reportRows(board, "activities").stream().map(item -> Arrays.asList(item.getString("name"), sourceName(item.getString("source")), + item.getString("date"), String.valueOf(item.getInt("participants")), String.valueOf(item.getInt("total")), reportPercent(item.get("rate")))).collect(Collectors.toList())); + reportHeading(document, "人员构成与年龄", true); + for (String key : Arrays.asList("composition", "ages")) { + String label = "ages".equals(key) ? "年龄分层" : "人员构成"; + List rows = reportRows(board, key); + reportHeading(document, label, false); + reportParagraph(document, compositionAnalysis(rows, label, joined), 11, false); + reportBars(document, rows, "share", label + "占比"); + reportDimensionTable(document, rows, false); + } + reportHeading(document, "性别参与情况", true); + List genders = reportRows(board, "genders"); + reportParagraph(document, compositionAnalysis(genders, "性别分布", joined), 11, false); + reportGenderPie(document, genders); + reportDimensionTable(document, genders, true); + reportParagraph(document, "饼图展示参与人员中的性别占比;表格中的参与率以该性别统计范围人数为分母,两者含义不同。", 10, false); + reportHeading(document, "职称参与情况", true); + List titles = reportRows(board, "titles"); + reportParagraph(document, "按具体职称统计,按统计范围人数从多到少排列,未填职称固定最后。零参与人数的职称仍保留。", 10, false); + reportParagraph(document, compositionAnalysis(titles, "职称分布", joined), 11, false); + // 分类较多时分组绘制,避免一张图缩得过小;表格仍包含全部具体职称。 + for (int offset = 0; offset < titles.size(); offset += 12) reportBars(document, + titles.subList(offset, Math.min(offset + 12, titles.size())), "rate", "职称参与率"); + reportDimensionTable(document, titles, true); + reportHeading(document, "单位参与情况", true); + List units = reportRows(board, "units"); + long participatingUnits = units.stream().filter(item -> item.getInt("participants") > 0).count(); + reportParagraph(document, "当前统计范围涉及 " + units.size() + " 个单位分类,其中 " + participatingUnits + " 个有有效参与人员。", 11, false); + reportParagraph(document, compositionAnalysis(units, "单位分布", joined), 11, false); + List ranked = units.stream().filter(this::hasReportCategory).filter(item -> item.getInt("participants") > 0) + .sorted(Comparator.comparingInt(item -> item.getInt("participants")).reversed() + .thenComparing(item -> item.getString("name"))).limit(10).collect(Collectors.toList()); + reportBars(document, ranked, "participants", "明确归属单位参与人数前十"); + NutMap highestRate = units.stream().filter(this::hasReportCategory).filter(item -> item.get("rate") != null && item.getInt("participants") > 0) + .max(Comparator.comparingDouble(item -> item.getDouble("rate"))).orElse(null); + if (highestRate != null) reportParagraph(document, "有明确归属的单位中,最高参与率为 " + reportPercent(highestRate.get("rate")) + ",对应单位详见下表。同率单位并列;参与率需结合范围人数判断,不能仅以比例评价活动效果。", 11, false); + reportDimensionTable(document, units, true); + reportHeading(document, "统计口径与数据说明", true); + for (Object note : (List) board.get("notes")) { + // 工会板块已停用,报告不重新引入该维度;年龄截至日期仍予以保留。 + String value = note.toString().replace("工会按人员当前所属工会统计;", ""); + reportParagraph(document, value, 10, false); + } + reportParagraph(document, "各项分析仅描述当前查询范围内的分布与差异,不据此推断参与意愿、活动质量或差异产生的原因。导出按查询条件重新统计,期间档案或报名发生变动时,数据可能与此前页面结果不同。", 10, false); + document.write(output); + return output.toByteArray(); + } + } + + /** 报告行只读取服务端聚合数组;无行时使用空列表,让表格明确输出暂无数据。 */ + @SuppressWarnings("unchecked") + private List reportRows(NutMap board, String key) { + return (List) board.getOrDefault(key, java.util.Collections.emptyList()); + } + + /** 固定来源代码对应中文名称,导出不使用客户端提供的显示名称。 */ + private String sourceName(String source) { + switch (source) { + case "training": return "品牌活动"; + case "family": return "亲子活动"; + case "sports": return "体育活动"; + case "culture": return "校工会文化活动"; + default: return "全部来源"; + } + } + + /** 缺失分母不解释为零;百分比使用与后端相同的两位小数精度。 */ + private String reportPercent(Object value) { + return value == null ? "—" : String.format(java.util.Locale.ROOT, "%.2f%%", ((Number) value).doubleValue()); + } + + /** + * 报告排名仅比较有明确分类的行;精确识别系统占位名称及空值,避免模糊匹配误排真实类别。 + * 不修改原始分组数据,未知人员仍计入明细、总人数、参与率分母和构成占比分母。 + */ + private boolean hasReportCategory(NutMap row) { + if (row == null) return false; + String name = text(row.getString("name")).strip(); + if (name.isEmpty()) return false; + return !Arrays.asList("null", "undefined", "unknown", "n/a", "na", "-", "—", "--", + "空", "为空", "未知", "未填", "未填写", "未设置", "未配置", "未提供", "未归属", + "未填职称", "职称未知", "未知职称", "未填写职称", "人员类型未知", "年龄未知", "性别未知", + "未归属单位", "单位未知", "未归属工会").contains(name.toLowerCase(java.util.Locale.ROOT)); + } + + /** 只在明确分类中描述人数最大群体,保留并列;占比仍以全部参与人员为分母。 */ + private String compositionAnalysis(List rows, String label, int total) { + if (total == 0) return label + "暂无有效参与人员,暂不进行分布分析。"; + List comparable = rows.stream().filter(this::hasReportCategory).collect(Collectors.toList()); + int maximum = comparable.stream().mapToInt(item -> item.getInt("participants")).max().orElse(0); + if (maximum == 0) return label + "暂无可用于比较的明确分类数据;未知或未填写类别仍保留在图表和明细中。"; + String names = comparable.stream().filter(item -> item.getInt("participants") == maximum) + .map(item -> item.getString("name")).collect(Collectors.joining("、")); + boolean tied = comparable.stream().filter(item -> item.getInt("participants") == maximum).count() > 1; + return label + "中,在有明确分类的人员中,参与人数最多的是“" + names + "”," + (tied ? "各为 " : "共 ") + maximum + " 人," + (tied ? "各占" : "占") + "全部参与人员的 " + + reportPercent(rate(maximum, total)) + "。未知或未填写类别不参与最多比较,其人数仍计入占比分母,完整数据见下表。"; + } + + /** 正文使用中文字体、明确段后距;表格与图题另设字号以控制多页报告的可读性。 */ + private XWPFParagraph reportParagraph(XWPFDocument document, String text, int size, boolean bold) { + XWPFParagraph paragraph = document.createParagraph(); + paragraph.setSpacingAfter(110); paragraph.setSpacingBetween(1.2); + XWPFRun run = paragraph.createRun(); run.setFontFamily("Microsoft YaHei"); + run.setFontFamily("Microsoft YaHei", XWPFRun.FontCharRange.eastAsia); + run.setFontSize(size); run.setBold(bold); run.setText(text); + return paragraph; + } + + /** 大章节从新页开始,标题与后文保持同页,避免孤立标题。 */ + private void reportHeading(XWPFDocument document, String text, boolean newPage) { + XWPFParagraph paragraph = reportParagraph(document, text, 14, true); + paragraph.setStyle("Heading1"); paragraph.setPageBreak(newPage); paragraph.setKeepNext(true); + paragraph.getRuns().get(0).setColor("17477D"); + paragraph.setSpacingBefore(160); + } + + /** 完整导出每一行,不受网页当前页和图表滑块影响;跨页表格重复表头且避免单行拆页。 */ + private void reportTable(XWPFDocument document, List headers, List> data) { + XWPFTable table = document.createTable(1, headers.size()); + table.setWidth("100%"); + table.setCellMargins(60, 100, 60, 100); + table.setInsideHBorder(XWPFTable.XWPFBorderType.SINGLE, 4, 0, "DCE6F2"); + table.setInsideVBorder(XWPFTable.XWPFBorderType.SINGLE, 4, 0, "DCE6F2"); + table.setTopBorder(XWPFTable.XWPFBorderType.SINGLE, 4, 0, "DCE6F2"); + table.setBottomBorder(XWPFTable.XWPFBorderType.SINGLE, 4, 0, "DCE6F2"); + table.setLeftBorder(XWPFTable.XWPFBorderType.SINGLE, 4, 0, "DCE6F2"); + table.setRightBorder(XWPFTable.XWPFBorderType.SINGLE, 4, 0, "DCE6F2"); + table.getCTTbl().getTblPr().addNewTblLayout().setType(STTblLayoutType.FIXED); + CTTblGrid grid = table.getCTTbl().getTblGrid(); + if (grid == null) grid = table.getCTTbl().addNewTblGrid(); + while (grid.sizeOfGridColArray() > 0) grid.removeGridCol(0); + for (int column = 0; column < headers.size(); column++) { + grid.addNewGridCol().setW(BigInteger.valueOf(headers.size() >= 4 ? (column == 0 ? 3400 : 6238 / (headers.size() - 1)) : 4819)); + } + List> content = new ArrayList<>(); content.add(headers); + content.addAll(data.isEmpty() ? java.util.Collections.singletonList(java.util.Collections.nCopies(headers.size(), "—")) : data); + for (int index = 0; index < content.size(); index++) { + XWPFTableRow row = index == 0 ? table.getRow(0) : table.createRow(); + row.setCantSplitRow(true); + if (index == 0) row.setRepeatHeader(true); + for (int column = 0; column < headers.size(); column++) { + XWPFTableCell cell = row.getCell(column); + cell.setWidth(headers.size() >= 4 ? (column == 0 ? "3400" : String.valueOf(6238 / (headers.size() - 1))) : "4819"); + cell.setColor(index == 0 ? "EAF1FC" : index % 2 == 0 ? "F7FAFF" : "FFFFFF"); + XWPFParagraph paragraph = cell.getParagraphs().get(0); + paragraph.setSpacingAfter(0); paragraph.setSpacingBefore(0); paragraph.setSpacingBetween(1.1); + XWPFRun run = paragraph.createRun(); run.setFontFamily("Microsoft YaHei"); + run.setFontFamily("Microsoft YaHei", XWPFRun.FontCharRange.eastAsia); + run.setFontSize(9); run.setBold(index == 0); run.setText(content.get(index).get(column)); + } + } + if (data.isEmpty()) reportParagraph(document, "暂无数据。", 10, false); + } + + /** 人数、范围分母、参与率及占比使用清晰列名;不对零分母伪造百分比。 */ + private void reportDimensionTable(XWPFDocument document, List rows, boolean includeRate) { + List headers = new ArrayList<>(Arrays.asList("分类", "范围人数", "参与人数", "参与人员占比")); + if (includeRate) headers.add("参与率"); + List> data = new ArrayList<>(); + for (NutMap row : rows) { + List values = new ArrayList<>(Arrays.asList(row.getString("name"), String.valueOf(row.getInt("total")), + String.valueOf(row.getInt("participants")), reportPercent(row.get("share")))); + if (includeRate) values.add(reportPercent(row.get("rate"))); + data.add(values); + } + reportTable(document, headers, data); + } + + /** 服务端绘制横向条形图,避免依赖浏览器截图;图中文字用完整名称或分行,数字保持原始口径。 */ + private void reportBars(XWPFDocument document, List rows, String field, String label) throws IOException { + if (rows.isEmpty()) return; + if (rows.size() > 12) { + for (int offset = 0; offset < rows.size(); offset += 12) reportBars(document, + rows.subList(offset, Math.min(offset + 12, rows.size())), field, label); + return; + } + int height = 64 + rows.size() * 52; + BufferedImage picture = new BufferedImage(1100, height, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = picture.createGraphics(); + try { + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + graphics.setColor(Color.WHITE); graphics.fillRect(0, 0, 1100, height); + graphics.setFont(new Font("Microsoft YaHei", Font.PLAIN, 22)); graphics.setColor(new Color(0x17477D)); + graphics.drawString(label, 10, 30); + boolean percent = "share".equals(field) || "rate".equals(field); + double maximum = percent ? 100 : Math.max(1, rows.stream().filter(item -> item.get(field) != null) + .mapToDouble(item -> ((Number) item.get(field)).doubleValue()).max().orElse(1)); + for (int index = 0; index < rows.size(); index++) { + NutMap row = rows.get(index); int y = 60 + index * 52; + String name = row.getString("name"); graphics.setColor(new Color(0x344A65)); + graphics.setFont(new Font("Microsoft YaHei", Font.PLAIN, 18)); + // 图表标签最多两行;完整名称始终保留在紧随的明细表中。 + graphics.drawString(name.length() > 17 ? name.substring(0, 17) : name, 10, y + 10); + if (name.length() > 17) graphics.drawString(name.length() > 33 ? name.substring(17, 32) + "…" : name.substring(17), 10, y + 31); + Object raw = row.get(field); double value = raw == null ? 0 : ((Number) raw).doubleValue(); + graphics.setColor(new Color(0xEDF3FD)); graphics.fillRect(350, y - 9, 610, 24); + graphics.setColor(new Color(0x2674F5)); graphics.fillRect(350, y - 9, (int) Math.round(610 * value / maximum), 24); + graphics.setColor(new Color(0x344A65)); + graphics.drawString(raw == null ? ("value".equals(field) ? "未开始" : "—") : percent ? reportPercent(raw) : String.valueOf(((Number) raw).intValue()), 970, y + 10); + } + } finally { graphics.dispose(); } + reportPicture(document, picture, label); + } + + /** 性别饼图以有效参与人数为面积依据,零参与时明确展示无数据,不绘制误导性满圆。 */ + private void reportGenderPie(XWPFDocument document, List rows) throws IOException { + int total = rows.stream().mapToInt(item -> item.getInt("participants")).sum(); + if (total == 0) { reportParagraph(document, "暂无性别参与数据。", 10, false); return; } + BufferedImage picture = new BufferedImage(1100, 380, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = picture.createGraphics(); + try { + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + graphics.setColor(Color.WHITE); graphics.fillRect(0, 0, 1100, 380); + Color[] colors = {new Color(0x2674F5), new Color(0x8BB8FC), new Color(0xBAC6D8)}; + int cumulative = 0, start = 90; + for (int index = 0; index < rows.size(); index++) { + NutMap row = rows.get(index); cumulative += row.getInt("participants"); + int end = 90 - (int) Math.round(cumulative * 360.0 / total); + graphics.setColor(colors[index % colors.length]); graphics.fillArc(180, 20, 330, 330, start, end - start); start = end; + graphics.fillRect(610, 85 + index * 70, 22, 22); + graphics.setColor(new Color(0x344A65)); graphics.setFont(new Font("Microsoft YaHei", Font.PLAIN, 24)); + graphics.drawString(row.getString("name") + " " + row.getInt("participants") + " 人 " + reportPercent(row.get("share")), 650, 106 + index * 70); + } + } finally { graphics.dispose(); } + reportPicture(document, picture, "参与人员性别占比"); + } + + /** 将图表嵌入 DOCX,图片宽度不超过正文区域;资源全部在内存中关闭。 */ + private void reportPicture(XWPFDocument document, BufferedImage picture, String description) throws IOException { + try (ByteArrayOutputStream image = new ByteArrayOutputStream()) { + ImageIO.write(picture, "png", image); + XWPFParagraph paragraph = document.createParagraph(); paragraph.setSpacingAfter(100); + try (ByteArrayInputStream input = new ByteArrayInputStream(image.toByteArray())) { + paragraph.createRun().addPicture(input, Document.PICTURE_TYPE_PNG, description, + Units.toEMU(480), Units.toEMU(480.0 * picture.getHeight() / picture.getWidth())); + } catch (org.apache.poi.openxml4j.exceptions.InvalidFormatException exception) { + throw new IOException("报告图表生成失败", exception); + } + } + } + /** 半年未开始或人员分母为零时不产生有效参与率。 */ private NutMap semester(String name, int activities, int participants, int total, boolean available) { return new NutMap().setv("name", name).setv("activityCount", activities) diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 8128e03..87e2ef2 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -1,40 +1,139 @@ - - - [%-5level] %d{HH:mm:ss.SSS} [%thread] %logger - %msg%n - - + + - - - /data/budwk5mini/logs/mini-%d{yyyy-MM-dd}.log - 15 - + + - [%-5level] %d{HH:mm:ss.SSS} %logger - %msg%n + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - 0 - - 256 - - + + + + + + ${LOG_HOME}/app.log + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_HOME}/app-%d{yyyy-MM-dd}.log + 300 + - + + - - + + - \ No newline at end of file + + + + + + + + + + + + + + diff --git a/src/main/resources/views/platform/databoard/culturalSports.html b/src/main/resources/views/platform/databoard/culturalSports.html index eba82de..b82a8b4 100644 --- a/src/main/resources/views/platform/databoard/culturalSports.html +++ b/src/main/resources/views/platform/databoard/culturalSports.html @@ -1,7 +1,7 @@ - +