文体活动看板导出word报告

This commit is contained in:
2026-09-10 17:09:45 +08:00
parent 443e22d62d
commit 8748fa0c7d
5 changed files with 564 additions and 30 deletions
@@ -46,23 +46,55 @@ public class CulturalSportsBoardController {
public Object data(@Param("year") Integer year, @Param("period") String period, public Object data(@Param("year") Integer year, @Param("period") String period,
@Param("source") String source, @Param("unitId") String unitId, @Param("source") String source, @Param("unitId") String unitId,
@Param("activityId") String activityId) { @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()) { if (year == null || year < 1900 || year > LocalDate.now().getYear()) {
return Result.error("请选择有效年度,不能查询未来年度"); return ("请选择有效年度,不能查询未来年度");
} }
if (!Arrays.asList("all", "first", "second").contains(period) if (!Arrays.asList("all", "first", "second").contains(period)
|| !Arrays.asList("all", "culture", "sports", "training", "family").contains(source)) { || !Arrays.asList("all", "culture", "sports", "training", "family").contains(source)) {
return Result.error("统计周期或活动来源不正确"); return ("统计周期或活动来源不正确");
} }
if (unitId != null && unitId.length() > 64) { if (unitId != null && unitId.length() > 64) {
return Result.error("单位参数不正确"); return ("单位参数不正确");
} }
// 活动键来自 options;限制长度及来源前缀,防止跨来源串用筛选值。 // 活动键来自 options;限制长度及来源前缀,防止跨来源串用筛选值。
if (activityId != null && !activityId.isEmpty()) { if (activityId != null && !activityId.isEmpty()) {
String prefix = "family".equals(source) ? "family:" : "training".equals(source) ? "training:" : "activity:"; String prefix = "family".equals(source) ? "family:" : "training".equals(source) ? "training:" : "activity:";
if (activityId.length() > 100 || (!"all".equals(source) && !activityId.startsWith(prefix))) { 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;
} }
} }
@@ -21,7 +21,14 @@ public interface CulturalSportsBoardService {
* composition/ages/titles/genders 各维度人数与比例,notes 为统计口径说明。 * composition/ages/titles/genders 各维度人数与比例,notes 为统计口径说明。
* 参与率为 0—100 的百分数;分母为零时 rate 为 null。 * 参与率为 0—100 的百分数;分母为零时 rate 为 null。
* 分母为活动名单(缺失时用当前在职人员)与有效报名人员并集,按人员 ID 跨活动去重。 * 分母为活动名单(缺失时用当前在职人员)与有效报名人员并集,按人员 ID 跨活动去重。
* activities 为当前周期全部活动的名称、来源、开始日期、参与人数、范围人数与参与率;
* fallbackActivities 列出采用默认在职范围的活动,notes 说明统计口径。 * fallbackActivities 列出采用默认在职范围的活动,notes 说明统计口径。
*/ */
NutMap statistics(int year, String period, String source, String unitId, String activityId); 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;
} }
@@ -9,6 +9,19 @@ import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap; 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.LocalDate;
import java.time.Period; import java.time.Period;
import java.time.YearMonth; import java.time.YearMonth;
@@ -228,6 +241,25 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic
} }
// 范围外有效报名补入分母;跨活动取并集,每名人员只计一次。 // 范围外有效报名补入分母;跨活动取并集,每名人员只计一次。
population.addAll(selectedPeople); population.addAll(selectedPeople);
// 报告活动明细复用本次已查询的名单与报名集合;单活动分母也必须补入范围外报名人员。
List<NutMap> activityRows = new ArrayList<>();
for (NutMap event : selectedEvents) {
Set<String> roster = scopes.get(text(event.getString("scope_id")));
if (roster == null || roster.isEmpty()) roster = currentStaff;
Set<String> 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<String> 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(); boolean rateAvailable = !population.isEmpty();
Set<String> displayedPeople = population; Set<String> displayedPeople = population;
@@ -317,11 +349,324 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic
.setv("composition", rows(composition, selectedPeople.size(), rateAvailable)) .setv("composition", rows(composition, selectedPeople.size(), rateAvailable))
.setv("ages", rows(ages, selectedPeople.size(), rateAvailable)).setv("titles", titleRows) .setv("ages", rows(ages, selectedPeople.size(), rateAvailable)).setv("titles", titleRows)
.setv("genders", rows(genders, selectedPeople.size(), rateAvailable)).setv("notes", notes) .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("periodName", periodName).setv("scopeLabel", SCOPE_LABEL)
.setv("asOf", cutoff.toString()).setv("year", year); .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<NutMap> 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<NutMap> 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<NutMap> 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<NutMap> 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<NutMap> 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<NutMap> 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<NutMap> ranked = units.stream().filter(this::hasReportCategory).filter(item -> item.getInt("participants") > 0)
.sorted(Comparator.<NutMap>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<NutMap> reportRows(NutMap board, String key) {
return (List<NutMap>) 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<NutMap> rows, String label, int total) {
if (total == 0) return label + "暂无有效参与人员,暂不进行分布分析。";
List<NutMap> 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<String> headers, List<List<String>> 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<List<String>> 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<NutMap> rows, boolean includeRate) {
List<String> headers = new ArrayList<>(Arrays.asList("分类", "范围人数", "参与人数", "参与人员占比"));
if (includeRate) headers.add("参与率");
List<List<String>> data = new ArrayList<>();
for (NutMap row : rows) {
List<String> 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<NutMap> 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<NutMap> 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) { private NutMap semester(String name, int activities, int participants, int total, boolean available) {
return new NutMap().setv("name", name).setv("activityCount", activities) return new NutMap().setv("name", name).setv("activityCount", activities)
+121 -22
View File
@@ -1,40 +1,139 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<configuration scan="false" scanPeriod="60000" debug="false"> <configuration scan="false" scanPeriod="60000" debug="false">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <!-- 日志存储路径 -->
<layout class="ch.qos.logback.classic.PatternLayout"> <property name="LOG_HOME" value="./logs"/>
<pattern>[%-5level] %d{HH:mm:ss.SSS} [%thread] %logger - %msg%n</pattern>
</layout>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <!-- 控制台输出 -->
<rollingPolicy class="org.nutz.boot.starter.logback.exts.logfile.LogfileTimeBasedRollingPolicy"> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<fileNamePattern>/data/budwk5mini/logs/mini-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>15</maxHistory>
</rollingPolicy>
<encoder> <encoder>
<pattern>[%-5level] %d{HH:mm:ss.SSS} %logger - %msg%n</pattern> <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder> </encoder>
</appender> </appender>
<!-- 异步输出 -->
<appender name ="ASYNC" class= "ch.qos.logback.classic.AsyncAppender"> <!--
<!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 --> #################################################################################
<discardingThreshold>0</discardingThreshold> # #
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 --> # 全文输出日志(一个文件存储)start #
<queueSize>256</queueSize> # #
<!-- 添加附加的appender,最多只能添加一个 --> #################################################################################
<appender-ref ref ="FILE"/> -->
<!-- 所有级别日志写入同一个文件 -->
<appender name="ALL_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/app.log</file>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/app-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>300</maxHistory>
</rollingPolicy>
</appender> </appender>
<logger name="java" additivity="false" /> <!-- 框架日志级别(按需调整) -->
<logger name="org.eclipse.jetty" level="INFO"/> <logger name="org.eclipse.jetty" level="INFO"/>
<logger name="org.quartz" level="INFO"/> <logger name="org.quartz" level="INFO"/>
<logger name="org.nutz" level="DEBUG"/> <logger name="org.nutz" level="DEBUG"/>
<!-- root loggerDEBUG 级别,输出到控制台和全量文件 -->
<root level="DEBUG"> <root level="DEBUG">
<appender-ref ref="STDOUT" /> <appender-ref ref="STDOUT"/>
<appender-ref ref="ASYNC" /> <appender-ref ref="ALL_FILE"/>
</root> </root>
<!--
#################################################################################
# #
# 全文输出日志(一个文件存储)end #
# #
#################################################################################
-->
<!--
#################################################################################
# #
# debug、info、error分文件输出 start #
# #
#################################################################################
-->
<!-- DEBUG级别日志文件配置 -->
<!--<appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/debug.log</file>
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>DEBUG</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/debug-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>300</maxHistory>
</rollingPolicy>
</appender>
&lt;!&ndash; INFO级别日志文件配置 &ndash;&gt;
<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/info.log</file>
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/info-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>300</maxHistory>
</rollingPolicy>
</appender>
&lt;!&ndash; ERROR级别日志文件配置 &ndash;&gt;
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/error.log</file>
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/error-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>300</maxHistory>
</rollingPolicy>
</appender>
&lt;!&ndash; 框架日志级别 &ndash;&gt;
&lt;!&ndash; <logger name="java" additivity="false" />&ndash;&gt;
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="org.quartz" level="INFO"/>
<logger name="org.nutz" level="DEBUG"/>
&lt;!&ndash; 日志级别和appender的关联 &ndash;&gt;
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
<appender-ref ref="DEBUG_FILE"/>
<appender-ref ref="INFO_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>-->
<!--
#################################################################################
# #
# debug、info、error分文件输出 end #
# #
#################################################################################
-->
</configuration> </configuration>
@@ -1,7 +1,7 @@
<!--# <!--#
layout("/layouts/platform.html"){ layout("/layouts/platform.html"){
#--> #-->
<!-- Vue、Element UI、ECharts、v-chart 及图标字体均由 platform.html 引入,避免重复加载。 --> <!-- Vue、Element UI、Axios、ECharts、v-chart 及图标字体均由 platform.html 引入,避免重复加载。 -->
<style> <style>
#app.cultural-sports-board { background: #f5f8fd; padding: 18px; color: #26364b; overflow: auto; } #app.cultural-sports-board { background: #f5f8fd; padding: 18px; color: #26364b; overflow: auto; }
.cultural-sports-board * { box-sizing: border-box; } .cultural-sports-board * { box-sizing: border-box; }
@@ -130,6 +130,9 @@ layout("/layouts/platform.html"){
<div class="filter-actions"> <div class="filter-actions">
<el-button type="primary" size="small" icon="el-icon-search" :loading="tableLoading" :disabled="optionsLoading" @click="loadBoard">查询</el-button> <el-button type="primary" size="small" icon="el-icon-search" :loading="tableLoading" :disabled="optionsLoading" @click="loadBoard">查询</el-button>
<el-button size="small" :disabled="tableLoading || optionsLoading" @click="resetFilters">重置</el-button> <el-button size="small" :disabled="tableLoading || optionsLoading" @click="resetFilters">重置</el-button>
<el-button size="small" icon="el-icon-download" :loading="exportLoading"
:disabled="!canExport" @click="exportReport">导出报告</el-button>
<span v-if="board && !queryUnchanged" class="chart-subtitle">条件已变更,请先查询再导出</span>
</div> </div>
</div> </div>
@@ -244,6 +247,8 @@ layout("/layouts/platform.html"){
data() { data() {
return { return {
tableLoading: false, tableLoading: false,
exportLoading: false,
queriedFilters: null,
optionsLoading: false, optionsLoading: false,
errorMessage: '', errorMessage: '',
options: {years: [new Date().getFullYear()], sources: [], units: [], activities: []}, options: {years: [new Date().getFullYear()], sources: [], units: [], activities: []},
@@ -263,6 +268,13 @@ layout("/layouts/platform.html"){
&& (this.filters.source === 'all' || activity.source === this.filters.source) && (this.filters.source === 'all' || activity.source === this.filters.source)
&& (this.filters.period === 'all' || (this.filters.period === 'first' ? activity.month <= 6 : activity.month > 6))); && (this.filters.period === 'all' || (this.filters.period === 'first' ? activity.month <= 6 : activity.month > 6)));
}, },
/** 只导出最后一次成功查询的条件,避免尚未查询的筛选值与当前图表混用。 */
queryUnchanged() {
return this.queriedFilters && Object.keys(this.filters).every((key) => this.filters[key] === this.queriedFilters[key]);
},
canExport() {
return !!this.board && !!this.queryUnchanged && !this.tableLoading && !this.optionsLoading && !this.exportLoading;
},
summary() { return this.board ? this.board.summary : {}; }, summary() { return this.board ? this.board.summary : {}; },
units() { return this.board ? this.board.units : []; }, units() { return this.board ? this.board.units : []; },
visibleUnits() { visibleUnits() {
@@ -343,6 +355,7 @@ layout("/layouts/platform.html"){
return; return;
} }
this.board = res.data; this.board = res.data;
this.queriedFilters = params;
this.$set(this.unitPage, 'number', 1); this.$set(this.unitPage, 'number', 1);
this.buildCharts(); this.buildCharts();
}, (xhr, status) => { }, (xhr, status) => {
@@ -354,10 +367,47 @@ layout("/layouts/platform.html"){
}, },
showFailure(message) { showFailure(message) {
this.board = null; this.board = null;
this.queriedFilters = null;
this.errorMessage = message; this.errorMessage = message;
Object.keys(this.charts).forEach((key) => this.$set(this.charts, key, emptyChart('加载失败,请重新查询'))); Object.keys(this.charts).forEach((key) => this.$set(this.charts, key, emptyChart('加载失败,请重新查询')));
}, },
/**
* 以最后成功查询的 year、period、source、unitId、activityId 发起 POST,返回 DOCX Blob。
* 复用 platform.html 引入的 Axios(与项目已有文件下载一致);先校验响应类型再下载,避免保存登录页或 JSON 错误。
*/
exportReport() {
if (!this.canExport) return;
this.exportLoading = true;
const params = Object.assign({}, this.queriedFilters);
this._exportCancel = axios.CancelToken.source();
axios.post(endpoint + '/exportWord', $.param(params), {
headers: {'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'},
responseType: 'blob', cancelToken: this._exportCancel.token
}).then((res) => {
if (this._isDestroyed) return;
const type = (res.headers['content-type'] || '').toLowerCase();
if (type.indexOf('application/vnd.openxmlformats-officedocument.wordprocessingml.document') < 0 || !res.data.size) {
throw new Error('导出未返回有效 Word 文件,请检查登录状态并重新查询。');
}
const url = window.URL.createObjectURL(res.data);
const link = document.createElement('a');
link.href = url; link.download = '活动报告.docx'; link.style.display = 'none';
document.body.appendChild(link);
try { link.click(); } finally {
document.body.removeChild(link);
window.setTimeout(() => window.URL.revokeObjectURL(url), 1000);
}
}).catch((error) => {
if (!this._isDestroyed && !axios.isCancel(error)) {
this.$message.error(error.response ? '报告导出失败,请检查登录状态或稍后重试。' : error.message || '报告导出失败,请重试。');
}
}).finally(() => {
if (!this._isDestroyed) this.exportLoading = false;
this._exportCancel = null;
});
},
/** 统一百分比坐标与提示:人员构成使用占比,其余参与率使用各自档案总数作分母。 */ /** 统一百分比坐标与提示:人员构成使用占比,其余参与率使用各自档案总数作分母。 */
barChart(rows, field, horizontal) { barChart(rows, field, horizontal) {
if (!rows.length || rows.every((row) => row[field] == null)) return emptyChart(); if (!rows.length || rows.every((row) => row[field] == null)) return emptyChart();
@@ -467,6 +517,7 @@ layout("/layouts/platform.html"){
beforeDestroy() { beforeDestroy() {
$(document).off('pjax:beforeReplace.culturalSportsBoard', this._leaveBoard); $(document).off('pjax:beforeReplace.culturalSportsBoard', this._leaveBoard);
if (this._boardRequest) this._boardRequest.abort(); if (this._boardRequest) this._boardRequest.abort();
if (this._exportCancel) this._exportCancel.cancel('页面已关闭');
} }
}); });
})(); })();