文体活动看板导出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,
@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;
}
}
@@ -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;
}
@@ -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<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();
Set<String> 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<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) {
return new NutMap().setv("name", name).setv("activityCount", activities)
+122 -23
View File
@@ -1,40 +1,139 @@
<?xml version="1.0" encoding="UTF-8" ?>
<configuration scan="false" scanPeriod="60000" debug="false">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<pattern>[%-5level] %d{HH:mm:ss.SSS} [%thread] %logger - %msg%n</pattern>
</layout>
</appender>
<!-- 日志存储路径 -->
<property name="LOG_HOME" value="./logs"/>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="org.nutz.boot.starter.logback.exts.logfile.LogfileTimeBasedRollingPolicy">
<fileNamePattern>/data/budwk5mini/logs/mini-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>15</maxHistory>
</rollingPolicy>
<!-- 控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<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>
</appender>
<!-- 异步输出 -->
<appender name ="ASYNC" class= "ch.qos.logback.classic.AsyncAppender">
<!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>256</queueSize>
<!-- 添加附加的appender,最多只能添加一个 -->
<appender-ref ref ="FILE"/>
<!--
#################################################################################
# #
# 全文输出日志(一个文件存储)start #
# #
#################################################################################
-->
<!-- 所有级别日志写入同一个文件 -->
<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>
<logger name="java" additivity="false" />
<!-- 框架日志级别(按需调整) -->
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="org.quartz" level="INFO"/>
<logger name="org.nutz" level="DEBUG"/>
<!-- root loggerDEBUG 级别,输出到控制台和全量文件 -->
<root level="DEBUG">
<appender-ref ref="STDOUT" />
<appender-ref ref="ASYNC" />
<appender-ref ref="STDOUT"/>
<appender-ref ref="ALL_FILE"/>
</root>
</configuration>
<!--
#################################################################################
# #
# 全文输出日志(一个文件存储)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>
@@ -1,7 +1,7 @@
<!--#
layout("/layouts/platform.html"){
#-->
<!-- Vue、Element UI、ECharts、v-chart 及图标字体均由 platform.html 引入,避免重复加载。 -->
<!-- Vue、Element UI、Axios、ECharts、v-chart 及图标字体均由 platform.html 引入,避免重复加载。 -->
<style>
#app.cultural-sports-board { background: #f5f8fd; padding: 18px; color: #26364b; overflow: auto; }
.cultural-sports-board * { box-sizing: border-box; }
@@ -130,6 +130,9 @@ layout("/layouts/platform.html"){
<div class="filter-actions">
<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" icon="el-icon-download" :loading="exportLoading"
:disabled="!canExport" @click="exportReport">导出报告</el-button>
<span v-if="board && !queryUnchanged" class="chart-subtitle">条件已变更,请先查询再导出</span>
</div>
</div>
@@ -244,6 +247,8 @@ layout("/layouts/platform.html"){
data() {
return {
tableLoading: false,
exportLoading: false,
queriedFilters: null,
optionsLoading: false,
errorMessage: '',
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.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 : {}; },
units() { return this.board ? this.board.units : []; },
visibleUnits() {
@@ -343,6 +355,7 @@ layout("/layouts/platform.html"){
return;
}
this.board = res.data;
this.queriedFilters = params;
this.$set(this.unitPage, 'number', 1);
this.buildCharts();
}, (xhr, status) => {
@@ -354,10 +367,47 @@ layout("/layouts/platform.html"){
},
showFailure(message) {
this.board = null;
this.queriedFilters = null;
this.errorMessage = message;
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) {
if (!rows.length || rows.every((row) => row[field] == null)) return emptyChart();
@@ -467,6 +517,7 @@ layout("/layouts/platform.html"){
beforeDestroy() {
$(document).off('pjax:beforeReplace.culturalSportsBoard', this._leaveBoard);
if (this._boardRequest) this._boardRequest.abort();
if (this._exportCancel) this._exportCancel.cancel('页面已关闭');
}
});
})();