diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/controller/WordChartExportController.java b/backend/src/main/java/com/yfd/platform/qgc_export/controller/WordChartExportController.java index e6ae2f71..c4f2a8b1 100644 --- a/backend/src/main/java/com/yfd/platform/qgc_export/controller/WordChartExportController.java +++ b/backend/src/main/java/com/yfd/platform/qgc_export/controller/WordChartExportController.java @@ -1,17 +1,23 @@ package com.yfd.platform.qgc_export.controller; import com.yfd.platform.qgc_export.domain.word.WordChartExportRequest; +import com.yfd.platform.qgc_export.domain.word.WordChartVo; +import com.yfd.platform.qgc_export.domain.word.WordSeriesVo; import com.yfd.platform.qgc_export.service.IWordChartExportService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.servlet.http.HttpServletResponse; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.Arrays; +import java.util.List; + /** * Word 图表导出控制器 *

@@ -37,4 +43,133 @@ public class WordChartExportController { public void exportWord(@RequestBody WordChartExportRequest request, HttpServletResponse response) { wordChartExportService.exportWord(request, response); } + + /** + * 返回一份带多种图表、多系列、自定义颜色的模拟请求数据,便于前端参考或直接复制。 + */ + @GetMapping("/demo") + @Operation(summary = "获取模拟导出请求数据(含 6 种图表类型 + 自定义颜色)") + public WordChartExportRequest demo() { + return buildDemoRequest(); + } + + private WordChartExportRequest buildDemoRequest() { + WordChartExportRequest request = new WordChartExportRequest(); + request.setTitle("年度水电监测数据分析报告"); + request.setSubtitle("演示数据(含饼图/柱状图/折线图/折柱混合/堆叠/归一化堆叠)"); + + List months = Arrays.asList("1月", "2月", "3月", "4月", "5月", "6月"); + + // 1. 饼图:各站类设备数量占比(相邻分片留白 + 自定义颜色) + WordChartVo pie = new WordChartVo(); + pie.setType("pie"); + pie.setHeading("第一章 设备总体情况"); + pie.setSubHeading("1.1 各站类设备数量占比"); + pie.setTitle("各站类设备数量占比"); + pie.setDescription("图 1-1 各站类设备数量占比示意图"); + pie.setCategories(Arrays.asList("水质站", "水文站", "水温站", "视频设备", "AI分拣装置")); + pie.setColors(Arrays.asList("#4C72B0", "#DD8452", "#55A868", "#C44E52", "#8172B2")); + pie.setPieGap(3); + WordSeriesVo pieSeries = new WordSeriesVo(); + pieSeries.setName("数量"); + pieSeries.setValues(Arrays.asList(120.0, 85.0, 60.0, 45.0, 30.0)); + pie.setSeries(List.of(pieSeries)); + + // 2. 柱状图:多系列 + 自定义颜色 + WordChartVo bar = new WordChartVo(); + bar.setType("bar"); + bar.setHeading("第二章 基地设备分布"); + bar.setSubHeading("2.1 各基地设备数量对比"); + bar.setTitle("各基地设备数量对比"); + bar.setDescription("图 2-1 各基地设备数量对比示意图"); + bar.setXAxisName("基地"); + bar.setYAxisName("数量(台)"); + bar.setCategories(Arrays.asList("班多基地", "羊曲基地", "茨哈峡基地", "龙羊峡基地")); + WordSeriesVo bar1 = series("智能装备", "#4C72B0", 120.0, 90.0, 150.0, 80.0); + WordSeriesVo bar2 = series("监测设备", "#55A868", 60.0, 70.0, 95.0, 110.0); + WordSeriesVo bar3 = series("视频设备", "#DD8452", 40.0, 50.0, 60.0, 70.0); + bar.setSeries(Arrays.asList(bar1, bar2, bar3)); + + // 3. 折线图:月度趋势 + WordChartVo line = new WordChartVo(); + line.setType("line"); + line.setHeading("第三章 生态流量趋势"); + line.setSubHeading("3.1 月度达标率趋势"); + line.setTitle("月度生态流量达标率趋势"); + line.setDescription("图 3-1 2024/2025 年月度生态流量达标率变化趋势"); + line.setXAxisName("月份"); + line.setYAxisName("达标率(%)"); + line.setCategories(months); + WordSeriesVo line1 = series("2024年", "#C44E52", 92.0, 94.0, 90.0, 95.0, 96.0, 93.0); + WordSeriesVo line2 = series("2025年", "#4C72B0", 88.0, 91.0, 93.0, 92.0, 97.0, 98.0); + line.setSeries(Arrays.asList(line1, line2)); + + // 4. 折柱混合图:柱(主轴)+ 折线(副轴) + WordChartVo barLine = new WordChartVo(); + barLine.setType("barLine"); + barLine.setHeading("第四章 水量水质综合"); + barLine.setSubHeading("4.1 水量与浊度综合趋势"); + barLine.setTitle("水量与水质综合趋势"); + barLine.setDescription("图 4-1 下泄水量(柱)与浊度(折线)综合趋势"); + barLine.setXAxisName("月份"); + barLine.setYAxisName("下泄水量(万m³)"); + barLine.setYAxisNameRight("浊度(NTU)"); + barLine.setCategories(months); + WordSeriesVo barSeries = series("下泄水量", "#55A868", 0, 320.0, 350.0, 410.0, 500.0, 620.0, 780.0); + WordSeriesVo lineSeries = series("浊度", "#C44E52", 1, 12.0, 11.0, 9.0, 8.0, 6.0, 5.0); + barLine.setSeries(Arrays.asList(barSeries, lineSeries)); + + // 5. 堆叠柱状图 + WordChartVo stacked = new WordChartVo(); + stacked.setType("stackedBar"); + stacked.setHeading("第五章 基地设备构成"); + stacked.setSubHeading("5.1 设备类型构成"); + stacked.setTitle("各基地设备类型构成"); + stacked.setDescription("图 5-1 各基地设备类型构成堆叠图"); + stacked.setXAxisName("基地"); + stacked.setYAxisName("数量(台)"); + stacked.setCategories(Arrays.asList("班多基地", "羊曲基地", "茨哈峡基地")); + stacked.setSeries(Arrays.asList( + series("水质", "#4C72B0", 30.0, 20.0, 40.0), + series("水文", "#55A868", 25.0, 35.0, 30.0), + series("视频", "#DD8452", 15.0, 25.0, 20.0) + )); + + // 6. 归一化堆叠柱状图(百分比) + WordChartVo percent = new WordChartVo(); + percent.setType("percentStackedBar"); + percent.setHeading("第五章 基地设备构成"); + percent.setSubHeading("5.2 设备类型占比(归一化)"); + percent.setTitle("各基地设备类型占比(归一化)"); + percent.setDescription("图 5-2 各基地设备类型占比归一化堆叠图"); + percent.setXAxisName("基地"); + percent.setYAxisName("占比(%)"); + percent.setCategories(Arrays.asList("班多基地", "羊曲基地", "茨哈峡基地")); + percent.setSeries(Arrays.asList( + series("水质", "#4C72B0", 30.0, 20.0, 40.0), + series("水文", "#55A868", 25.0, 35.0, 30.0), + series("视频", "#DD8452", 15.0, 25.0, 20.0) + )); + + request.setCharts(Arrays.asList(pie, bar, line, barLine, stacked, percent)); + return request; + } + + private WordSeriesVo series(String name, String color, double... values) { + WordSeriesVo vo = new WordSeriesVo(); + vo.setName(name); + vo.setColor(color); + List list = new java.util.ArrayList<>(); + for (double v : values) { + list.add(v); + } + vo.setValues(list); + return vo; + } + + private WordSeriesVo series(String name, String color, int yAxisIndex, double... values) { + WordSeriesVo vo = series(name, color, values); + vo.setYAxisIndex(yAxisIndex); + return vo; + } } diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartVo.java b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartVo.java index 9ebeebc6..1120c781 100644 --- a/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartVo.java +++ b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartVo.java @@ -16,9 +16,18 @@ public class WordChartVo { @Schema(description = "图表类型:pie/bar/line/barLine/stackedBar/percentStackedBar") private String type; - @Schema(description = "图表标题") + @Schema(description = "图表标题(显示在图片内部)") private String title; + @Schema(description = "Word 文档中的一级标题(章节标题),显示在图片上方,可选") + private String heading; + + @Schema(description = "Word 文档中的二级标题,显示在图片上方、一级标题之后,可选") + private String subHeading; + + @Schema(description = "图表描述/图注文字,显示在图片下方,可选") + private String description; + @Schema(description = "X 轴名称") private String xAxisName; @@ -34,6 +43,12 @@ public class WordChartVo { @Schema(description = "系列数据列表") private List series = new ArrayList<>(); + @Schema(description = "饼图各分片颜色,与 categories 一一对应,支持 #RRGGBB / 颜色名,不传则用默认配色") + private List colors = new ArrayList<>(); + + @Schema(description = "饼图扇区之间留白宽度(像素),默认 2,用于相邻种类之间留空隙") + private Integer pieGap = 2; + @Schema(description = "是否显示数值标签") private boolean showValues = true; } diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordSeriesVo.java b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordSeriesVo.java index e05c772c..9d06fbfb 100644 --- a/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordSeriesVo.java +++ b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordSeriesVo.java @@ -21,4 +21,7 @@ public class WordSeriesVo { @Schema(description = "Y 轴索引:0=主轴(左), 1=副轴(右),默认 0。折柱混合图用") private Integer yAxisIndex = 0; + + @Schema(description = "系列颜色,支持 #RRGGBB / #AARRGGBB / 颜色名(如 red),不传则用默认配色") + private String color; } diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/util/ChartRenderUtil.java b/backend/src/main/java/com/yfd/platform/qgc_export/util/ChartRenderUtil.java index b5e8a77f..02319f56 100644 --- a/backend/src/main/java/com/yfd/platform/qgc_export/util/ChartRenderUtil.java +++ b/backend/src/main/java/com/yfd/platform/qgc_export/util/ChartRenderUtil.java @@ -15,6 +15,7 @@ import org.jfree.chart.plot.DatasetRenderingOrder; import org.jfree.chart.plot.PiePlot; import org.jfree.chart.plot.PlotOrientation; import org.jfree.chart.renderer.category.BarRenderer; +import org.jfree.chart.renderer.category.CategoryItemRenderer; import org.jfree.chart.renderer.category.LineAndShapeRenderer; import org.jfree.chart.renderer.category.StackedBarRenderer; import org.jfree.chart.ui.RectangleInsets; @@ -25,6 +26,7 @@ import java.awt.BasicStroke; import java.awt.Color; import java.awt.Font; import java.awt.image.BufferedImage; +import java.lang.reflect.Field; import java.text.DecimalFormat; import java.util.List; @@ -108,6 +110,20 @@ public final class ChartRenderUtil { plot.setLabelBackgroundPaint(new Color(255, 255, 255, 200)); plot.setLabelOutlinePaint(null); plot.setLabelShadowPaint(null); + + // 自定义分片颜色 + List colors = chart.getColors(); + for (int i = 0; i < categories.size(); i++) { + Color color = parseColor(i < colors.size() ? colors.get(i) : null); + if (color != null) { + plot.setSectionPaint(categories.get(i), color); + } + } + // 相邻分片之间留白:白色描边形成空隙 + float gap = chart.getPieGap() == null ? 2f : chart.getPieGap().floatValue(); + plot.setSectionOutlinesVisible(true); + plot.setDefaultSectionOutlinePaint(Color.WHITE); + plot.setDefaultSectionOutlineStroke(new BasicStroke(Math.max(gap, 0f))); return jfreeChart; } @@ -123,6 +139,7 @@ public final class ChartRenderUtil { CategoryPlot plot = jfreeChart.getCategoryPlot(); stylePlot(plot); BarRenderer renderer = (BarRenderer) plot.getRenderer(); + applySeriesPaints(renderer, chart.getSeries()); renderer.setDefaultItemLabelsVisible(chart.isShowValues()); renderer.setDefaultItemLabelGenerator( new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##"))); @@ -143,6 +160,7 @@ public final class ChartRenderUtil { CategoryPlot plot = jfreeChart.getCategoryPlot(); stylePlot(plot); LineAndShapeRenderer renderer = (LineAndShapeRenderer) plot.getRenderer(); + applySeriesPaints(renderer, chart.getSeries()); renderer.setDefaultShapesVisible(true); renderer.setDefaultShapesFilled(true); renderer.setDefaultStroke(new BasicStroke(2.0f)); @@ -168,6 +186,7 @@ public final class ChartRenderUtil { CategoryPlot plot = jfreeChart.getCategoryPlot(); stylePlot(plot); StackedBarRenderer renderer = (StackedBarRenderer) plot.getRenderer(); + applySeriesPaints(renderer, chart.getSeries()); renderer.setDefaultItemLabelsVisible(chart.isShowValues()); if (percent) { renderer.setDefaultItemLabelGenerator( @@ -232,6 +251,28 @@ public final class ChartRenderUtil { new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##"))); lineRenderer.setDefaultItemLabelFont(new Font(FONT_NAME, Font.PLAIN, 10)); + // 混合图:柱/线系列各自独立编号,分别应用自定义颜色 + int barIdx = 0; + int lineIdx = 0; + for (WordSeriesVo series : chart.getSeries()) { + if (series == null || series.getName() == null || series.getValues() == null) { + continue; + } + boolean onRight = series.getYAxisIndex() != null && series.getYAxisIndex() == 1; + Color color = parseColor(series.getColor()); + if (onRight) { + if (color != null) { + lineRenderer.setSeriesPaint(lineIdx, color); + } + lineIdx++; + } else { + if (color != null) { + barRenderer.setSeriesPaint(barIdx, color); + } + barIdx++; + } + } + CategoryPlot plot = new CategoryPlot(barDataset, domainAxis, leftAxis, barRenderer); plot.setDataset(1, lineDataset); plot.setRenderer(1, lineRenderer); @@ -305,4 +346,54 @@ public final class ChartRenderUtil { NumberAxis rangeAxis = (NumberAxis) plot.getRangeAxis(); rangeAxis.setTickLabelFont(new Font(FONT_NAME, Font.PLAIN, 12)); } + + /** + * 按系列顺序给渲染器应用自定义颜色(跳过无效系列) + */ + private static void applySeriesPaints(CategoryItemRenderer renderer, List seriesList) { + if (renderer == null || seriesList == null) { + return; + } + int idx = 0; + for (WordSeriesVo series : seriesList) { + if (series == null || series.getName() == null || series.getValues() == null) { + continue; + } + Color color = parseColor(series.getColor()); + if (color != null) { + renderer.setSeriesPaint(idx, color); + } + idx++; + } + } + + /** + * 解析颜色:支持 #RRGGBB / #AARRGGBB / 0xRRGGBB / Java 颜色名(如 red) + */ + private static Color parseColor(String color) { + if (color == null || color.isBlank()) { + return null; + } + String c = color.trim(); + try { + if (c.startsWith("#")) { + String hex = c.substring(1); + if (hex.length() == 6) { + return new Color(Integer.parseInt(hex, 16)); + } else if (hex.length() == 8) { + return new Color(Integer.parseInt(hex.substring(2), 16), true); + } + } else if (c.startsWith("0x") || c.startsWith("0X")) { + return new Color(Integer.parseInt(c.substring(2), 16)); + } else { + Field field = Color.class.getField(c.toUpperCase()); + if (field.getType() == Color.class) { + return (Color) field.get(null); + } + } + } catch (Exception ignored) { + // 解析失败则返回 null,使用默认配色 + } + return null; + } } diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/util/WordChartExportUtil.java b/backend/src/main/java/com/yfd/platform/qgc_export/util/WordChartExportUtil.java index e7df7274..258cd5e3 100644 --- a/backend/src/main/java/com/yfd/platform/qgc_export/util/WordChartExportUtil.java +++ b/backend/src/main/java/com/yfd/platform/qgc_export/util/WordChartExportUtil.java @@ -78,6 +78,12 @@ public final class WordChartExportUtil { byte[] png = toPngBytes(image); double imageHeightPt = IMAGE_WIDTH_PT * image.getHeight() / image.getWidth(); + // 一级标题 + appendHeading(document, chart.getHeading(), 16, true); + // 二级标题 + appendHeading(document, chart.getSubHeading(), 13, true); + + // 图片 XWPFParagraph paragraph = document.createParagraph(); paragraph.setAlignment(ParagraphAlignment.CENTER); XWPFRun run = paragraph.createRun(); @@ -89,11 +95,44 @@ public final class WordChartExportUtil { throw new IOException("插入图片失败", e); } } + + // 图表描述/图注 + appendDescription(document, chart.getDescription()); + // 每个图表后空一行,避免图片粘连 XWPFParagraph spacer = document.createParagraph(); spacer.setSpacingAfter(120); } + private static void appendHeading(XWPFDocument document, String text, int fontSize, boolean bold) { + if (text == null || text.isBlank()) { + return; + } + XWPFParagraph paragraph = document.createParagraph(); + paragraph.setAlignment(ParagraphAlignment.LEFT); + paragraph.setSpacingBefore(160); + paragraph.setSpacingAfter(80); + XWPFRun run = paragraph.createRun(); + run.setText(text); + run.setBold(bold); + run.setFontSize(fontSize); + run.setFontFamily("SimSun"); + } + + private static void appendDescription(XWPFDocument document, String text) { + if (text == null || text.isBlank()) { + return; + } + XWPFParagraph paragraph = document.createParagraph(); + paragraph.setAlignment(ParagraphAlignment.CENTER); + paragraph.setSpacingBefore(60); + XWPFRun run = paragraph.createRun(); + run.setText(text); + run.setFontSize(11); + run.setColor("808080"); + run.setFontFamily("SimSun"); + } + private static byte[] toPngBytes(BufferedImage image) throws IOException { try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { ImageIO.write(image, "png", out);