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);