com.alibaba
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
new file mode 100644
index 00000000..e6ae2f71
--- /dev/null
+++ b/backend/src/main/java/com/yfd/platform/qgc_export/controller/WordChartExportController.java
@@ -0,0 +1,40 @@
+package com.yfd.platform.qgc_export.controller;
+
+import com.yfd.platform.qgc_export.domain.word.WordChartExportRequest;
+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.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * Word 图表导出控制器
+ *
+ * 支持饼图、柱状图、折线图、折柱混合图、堆叠柱状图、堆叠柱状图归一化(百分比)等图形导出到 Word。
+ */
+@RestController
+@RequestMapping("/qgcExport/word")
+@Tag(name = "Word 图表导出")
+@Validated
+public class WordChartExportController {
+
+ @Resource
+ private IWordChartExportService wordChartExportService;
+
+ /**
+ * 导出 Word 文档(含多种图表)
+ *
+ * @param request 文档标题 + 图表列表
+ * @param response HttpServletResponse
+ */
+ @PostMapping("/export")
+ @Operation(summary = "导出 Word 文档(饼图/柱状图/折线图/折柱混合/堆叠/归一化堆叠)")
+ public void exportWord(@RequestBody WordChartExportRequest request, HttpServletResponse response) {
+ wordChartExportService.exportWord(request, response);
+ }
+}
diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/ChartType.java b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/ChartType.java
new file mode 100644
index 00000000..b5390a5f
--- /dev/null
+++ b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/ChartType.java
@@ -0,0 +1,52 @@
+package com.yfd.platform.qgc_export.domain.word;
+
+import lombok.Getter;
+
+/**
+ * Word 导出支持的图表类型
+ */
+@Getter
+public enum ChartType {
+
+ /** 饼图 */
+ PIE("饼图"),
+ /** 柱状图 */
+ BAR("柱状图"),
+ /** 折线图 */
+ LINE("折线图"),
+ /** 折柱混合图 */
+ BAR_LINE("折柱混合图"),
+ /** 堆叠柱状图 */
+ STACKED_BAR("堆叠柱状图"),
+ /** 堆叠柱状图(归一化/百分比) */
+ PERCENT_STACKED_BAR("堆叠柱状图归一化");
+
+ private final String desc;
+
+ ChartType(String desc) {
+ this.desc = desc;
+ }
+
+ /**
+ * 忽略大小写解析图表类型
+ */
+ public static ChartType from(String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+ String normalized = value.trim().replace("-", "_").toUpperCase();
+ for (ChartType type : values()) {
+ if (type.name().equals(normalized)) {
+ return type;
+ }
+ }
+ // 兼容别名
+ return switch (normalized) {
+ case "BARLINE", "BAR_LINE", "LINECOLUMN", "MIXED" -> BAR_LINE;
+ case "STACKED", "STACKEDBAR" -> STACKED_BAR;
+ case "PERCENT_STACKED", "PERCENTSTACKEDBAR", "NORMALIZED" -> PERCENT_STACKED_BAR;
+ case "COLUMN" -> BAR;
+ default -> null;
+ };
+ }
+}
diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartExportRequest.java b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartExportRequest.java
new file mode 100644
index 00000000..0d0f84e9
--- /dev/null
+++ b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartExportRequest.java
@@ -0,0 +1,24 @@
+package com.yfd.platform.qgc_export.domain.word;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Word 图表导出请求
+ */
+@Data
+@Schema(description = "Word 图表导出请求")
+public class WordChartExportRequest {
+
+ @Schema(description = "文档标题")
+ private String title;
+
+ @Schema(description = "文档副标题/说明(可选)")
+ private String subtitle;
+
+ @Schema(description = "图表列表,按顺序依次插入文档")
+ private List charts = new ArrayList<>();
+}
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
new file mode 100644
index 00000000..9ebeebc6
--- /dev/null
+++ b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordChartVo.java
@@ -0,0 +1,39 @@
+package com.yfd.platform.qgc_export.domain.word;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Word 文档中的单个图表描述
+ */
+@Data
+@Schema(description = "Word 图表")
+public class WordChartVo {
+
+ @Schema(description = "图表类型:pie/bar/line/barLine/stackedBar/percentStackedBar")
+ private String type;
+
+ @Schema(description = "图表标题")
+ private String title;
+
+ @Schema(description = "X 轴名称")
+ private String xAxisName;
+
+ @Schema(description = "Y 轴(左轴)名称")
+ private String yAxisName;
+
+ @Schema(description = "Y 轴(右轴)名称,折柱混合图用")
+ private String yAxisNameRight;
+
+ @Schema(description = "X 轴分类(如月份、站点名等)")
+ private List categories = new ArrayList<>();
+
+ @Schema(description = "系列数据列表")
+ private List series = new ArrayList<>();
+
+ @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
new file mode 100644
index 00000000..e05c772c
--- /dev/null
+++ b/backend/src/main/java/com/yfd/platform/qgc_export/domain/word/WordSeriesVo.java
@@ -0,0 +1,24 @@
+package com.yfd.platform.qgc_export.domain.word;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 图表中的单个系列(折柱混合图中可指定主轴/副轴)
+ */
+@Data
+@Schema(description = "图表系列")
+public class WordSeriesVo {
+
+ @Schema(description = "系列名称(图例)")
+ private String name;
+
+ @Schema(description = "系列数值,与 categories 一一对应")
+ private List values = new ArrayList<>();
+
+ @Schema(description = "Y 轴索引:0=主轴(左), 1=副轴(右),默认 0。折柱混合图用")
+ private Integer yAxisIndex = 0;
+}
diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/service/IWordChartExportService.java b/backend/src/main/java/com/yfd/platform/qgc_export/service/IWordChartExportService.java
new file mode 100644
index 00000000..de008719
--- /dev/null
+++ b/backend/src/main/java/com/yfd/platform/qgc_export/service/IWordChartExportService.java
@@ -0,0 +1,18 @@
+package com.yfd.platform.qgc_export.service;
+
+import com.yfd.platform.qgc_export.domain.word.WordChartExportRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+/**
+ * Word 图表导出服务
+ */
+public interface IWordChartExportService {
+
+ /**
+ * 根据图表配置生成 Word 文档并写入响应
+ *
+ * @param request 文档标题 + 图表列表
+ * @param response HttpServletResponse
+ */
+ void exportWord(WordChartExportRequest request, HttpServletResponse response);
+}
diff --git a/backend/src/main/java/com/yfd/platform/qgc_export/service/impl/WordChartExportServiceImpl.java b/backend/src/main/java/com/yfd/platform/qgc_export/service/impl/WordChartExportServiceImpl.java
new file mode 100644
index 00000000..0307db8d
--- /dev/null
+++ b/backend/src/main/java/com/yfd/platform/qgc_export/service/impl/WordChartExportServiceImpl.java
@@ -0,0 +1,52 @@
+package com.yfd.platform.qgc_export.service.impl;
+
+import cn.hutool.core.util.StrUtil;
+import com.yfd.platform.qgc_export.domain.word.WordChartExportRequest;
+import com.yfd.platform.qgc_export.service.IWordChartExportService;
+import com.yfd.platform.qgc_export.util.WordChartExportUtil;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Word 图表导出服务实现
+ */
+@Service
+public class WordChartExportServiceImpl implements IWordChartExportService {
+
+ @Override
+ public void exportWord(WordChartExportRequest request, HttpServletResponse response) {
+ byte[] data;
+ try {
+ data = WordChartExportUtil.generateWord(request);
+ } catch (IOException e) {
+ response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ return;
+ }
+
+ String fileName = resolveFileName(request);
+ response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ response.setContentLength(data.length);
+ response.setHeader("Content-Disposition",
+ "attachment; filename*=UTF-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8));
+
+ try (OutputStream os = response.getOutputStream()) {
+ os.write(data);
+ os.flush();
+ } catch (IOException e) {
+ response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ private String resolveFileName(WordChartExportRequest request) {
+ if (request != null && StrUtil.isNotBlank(request.getTitle())) {
+ return request.getTitle() + ".docx";
+ }
+ return "图表报告.docx";
+ }
+}
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
new file mode 100644
index 00000000..b5e8a77f
--- /dev/null
+++ b/backend/src/main/java/com/yfd/platform/qgc_export/util/ChartRenderUtil.java
@@ -0,0 +1,308 @@
+package com.yfd.platform.qgc_export.util;
+
+import com.yfd.platform.qgc_export.domain.word.ChartType;
+import com.yfd.platform.qgc_export.domain.word.WordChartVo;
+import com.yfd.platform.qgc_export.domain.word.WordSeriesVo;
+import org.jfree.chart.ChartFactory;
+import org.jfree.chart.JFreeChart;
+import org.jfree.chart.StandardChartTheme;
+import org.jfree.chart.axis.CategoryAxis;
+import org.jfree.chart.axis.NumberAxis;
+import org.jfree.chart.labels.StandardCategoryItemLabelGenerator;
+import org.jfree.chart.labels.StandardPieSectionLabelGenerator;
+import org.jfree.chart.plot.CategoryPlot;
+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.LineAndShapeRenderer;
+import org.jfree.chart.renderer.category.StackedBarRenderer;
+import org.jfree.chart.ui.RectangleInsets;
+import org.jfree.data.category.DefaultCategoryDataset;
+import org.jfree.data.general.DefaultPieDataset;
+
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.image.BufferedImage;
+import java.text.DecimalFormat;
+import java.util.List;
+
+/**
+ * 图表渲染工具:将结构化图表数据渲染为 BufferedImage,供 Word 导出插入。
+ *
+ * 支持:饼图、柱状图、折线图、折柱混合图、堆叠柱状图、堆叠柱状图归一化(百分比)。
+ */
+public final class ChartRenderUtil {
+
+ /** 默认渲染宽度 */
+ private static final int DEFAULT_WIDTH = 900;
+ /** 默认渲染高度 */
+ private static final int DEFAULT_HEIGHT = 520;
+ /** 中文字体 */
+ private static final String FONT_NAME = "SimSun";
+
+ static {
+ // 统一主题,避免中文乱码,去除阴影
+ StandardChartTheme theme = (StandardChartTheme) StandardChartTheme.createJFreeTheme();
+ Font extraLarge = new Font(FONT_NAME, Font.BOLD, 20);
+ Font large = new Font(FONT_NAME, Font.BOLD, 14);
+ Font regular = new Font(FONT_NAME, Font.PLAIN, 12);
+ Font small = new Font(FONT_NAME, Font.PLAIN, 10);
+ theme.setExtraLargeFont(extraLarge);
+ theme.setLargeFont(large);
+ theme.setRegularFont(regular);
+ theme.setSmallFont(small);
+ theme.setShadowVisible(false);
+ ChartFactory.setChartTheme(theme);
+ }
+
+ private ChartRenderUtil() {
+ }
+
+ /**
+ * 渲染图表为图片
+ */
+ public static BufferedImage render(WordChartVo chart) {
+ ChartType chartType = ChartType.from(chart.getType());
+ if (chartType == null) {
+ throw new IllegalArgumentException("不支持的图表类型: " + chart.getType());
+ }
+ JFreeChart jfreeChart = switch (chartType) {
+ case PIE -> buildPieChart(chart);
+ case BAR -> buildBarChart(chart);
+ case LINE -> buildLineChart(chart);
+ case BAR_LINE -> buildBarLineChart(chart);
+ case STACKED_BAR -> buildStackedBarChart(chart, false);
+ case PERCENT_STACKED_BAR -> buildStackedBarChart(chart, true);
+ };
+ if (jfreeChart == null) {
+ return null;
+ }
+ jfreeChart.setBackgroundPaint(Color.WHITE);
+ return jfreeChart.createBufferedImage(DEFAULT_WIDTH, DEFAULT_HEIGHT);
+ }
+
+ private static JFreeChart buildPieChart(WordChartVo chart) {
+ DefaultPieDataset dataset = new DefaultPieDataset<>();
+ List categories = chart.getCategories();
+ WordSeriesVo series = chart.getSeries().isEmpty() ? null : chart.getSeries().get(0);
+ if (categories == null || categories.isEmpty() || series == null || series.getValues() == null) {
+ return null;
+ }
+ List values = series.getValues();
+ for (int i = 0; i < categories.size(); i++) {
+ Double value = i < values.size() ? values.get(i) : 0.0;
+ dataset.setValue(categories.get(i), value == null ? 0.0 : value);
+ }
+
+ JFreeChart jfreeChart = ChartFactory.createPieChart(
+ chart.getTitle(), dataset, true, true, false);
+ PiePlot plot = (PiePlot) jfreeChart.getPlot();
+ plot.setLabelGenerator(new StandardPieSectionLabelGenerator(
+ "{0}: {1} ({2})", new DecimalFormat("0.##"), new DecimalFormat("0.0%")));
+ plot.setLabelFont(new Font(FONT_NAME, Font.PLAIN, 12));
+ plot.setLegendLabelGenerator(new StandardPieSectionLabelGenerator("{0}"));
+ plot.setBackgroundPaint(Color.WHITE);
+ plot.setOutlineVisible(false);
+ plot.setLabelBackgroundPaint(new Color(255, 255, 255, 200));
+ plot.setLabelOutlinePaint(null);
+ plot.setLabelShadowPaint(null);
+ return jfreeChart;
+ }
+
+ private static JFreeChart buildBarChart(WordChartVo chart) {
+ DefaultCategoryDataset dataset = buildDataset(chart);
+ JFreeChart jfreeChart = ChartFactory.createBarChart(
+ chart.getTitle(),
+ chart.getXAxisName(),
+ chart.getYAxisName(),
+ dataset,
+ PlotOrientation.VERTICAL,
+ true, true, false);
+ CategoryPlot plot = jfreeChart.getCategoryPlot();
+ stylePlot(plot);
+ BarRenderer renderer = (BarRenderer) plot.getRenderer();
+ renderer.setDefaultItemLabelsVisible(chart.isShowValues());
+ renderer.setDefaultItemLabelGenerator(
+ new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##")));
+ renderer.setDefaultItemLabelFont(new Font(FONT_NAME, Font.PLAIN, 10));
+ renderer.setItemMargin(0.1);
+ return jfreeChart;
+ }
+
+ private static JFreeChart buildLineChart(WordChartVo chart) {
+ DefaultCategoryDataset dataset = buildDataset(chart);
+ JFreeChart jfreeChart = ChartFactory.createLineChart(
+ chart.getTitle(),
+ chart.getXAxisName(),
+ chart.getYAxisName(),
+ dataset,
+ PlotOrientation.VERTICAL,
+ true, true, false);
+ CategoryPlot plot = jfreeChart.getCategoryPlot();
+ stylePlot(plot);
+ LineAndShapeRenderer renderer = (LineAndShapeRenderer) plot.getRenderer();
+ renderer.setDefaultShapesVisible(true);
+ renderer.setDefaultShapesFilled(true);
+ renderer.setDefaultStroke(new BasicStroke(2.0f));
+ renderer.setDefaultItemLabelsVisible(chart.isShowValues());
+ renderer.setDefaultItemLabelGenerator(
+ new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##")));
+ renderer.setDefaultItemLabelFont(new Font(FONT_NAME, Font.PLAIN, 10));
+ return jfreeChart;
+ }
+
+ private static JFreeChart buildStackedBarChart(WordChartVo chart, boolean percent) {
+ DefaultCategoryDataset dataset = buildDataset(chart);
+ if (percent) {
+ dataset = normalizePercent(dataset);
+ }
+ JFreeChart jfreeChart = ChartFactory.createStackedBarChart(
+ chart.getTitle(),
+ chart.getXAxisName(),
+ percent ? (chart.getYAxisName() == null ? "占比(%)" : chart.getYAxisName()) : chart.getYAxisName(),
+ dataset,
+ PlotOrientation.VERTICAL,
+ true, true, false);
+ CategoryPlot plot = jfreeChart.getCategoryPlot();
+ stylePlot(plot);
+ StackedBarRenderer renderer = (StackedBarRenderer) plot.getRenderer();
+ renderer.setDefaultItemLabelsVisible(chart.isShowValues());
+ if (percent) {
+ renderer.setDefaultItemLabelGenerator(
+ new StandardCategoryItemLabelGenerator("{2}%", new DecimalFormat("0.##")));
+ NumberAxis rangeAxis = (NumberAxis) plot.getRangeAxis();
+ rangeAxis.setRange(0.0, 100.0);
+ rangeAxis.setNumberFormatOverride(new DecimalFormat("0'%'"));
+ } else {
+ renderer.setDefaultItemLabelGenerator(
+ new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##")));
+ }
+ renderer.setDefaultItemLabelFont(new Font(FONT_NAME, Font.PLAIN, 10));
+ return jfreeChart;
+ }
+
+ private static JFreeChart buildBarLineChart(WordChartVo chart) {
+ DefaultCategoryDataset barDataset = new DefaultCategoryDataset();
+ DefaultCategoryDataset lineDataset = new DefaultCategoryDataset();
+ boolean hasBar = false;
+ boolean hasLine = false;
+ for (WordSeriesVo series : chart.getSeries()) {
+ if (series == null || series.getName() == null || series.getValues() == null) {
+ continue;
+ }
+ boolean onRight = series.getYAxisIndex() != null && series.getYAxisIndex() == 1;
+ DefaultCategoryDataset target = onRight ? lineDataset : barDataset;
+ if (onRight) {
+ hasLine = true;
+ } else {
+ hasBar = true;
+ }
+ for (int i = 0; i < chart.getCategories().size(); i++) {
+ Double value = i < series.getValues().size() ? series.getValues().get(i) : null;
+ target.addValue(value == null ? 0.0 : value, series.getName(), chart.getCategories().get(i));
+ }
+ }
+
+ // 若全部被归到一个轴,至少保证主轴有数据
+ if (!hasBar && hasLine) {
+ return buildLineChart(chart);
+ }
+
+ CategoryAxis domainAxis = new CategoryAxis(chart.getXAxisName());
+ domainAxis.setTickLabelFont(new Font(FONT_NAME, Font.PLAIN, 12));
+ NumberAxis leftAxis = new NumberAxis(chart.getYAxisName());
+ leftAxis.setTickLabelFont(new Font(FONT_NAME, Font.PLAIN, 12));
+ NumberAxis rightAxis = new NumberAxis(chart.getYAxisNameRight());
+ rightAxis.setTickLabelFont(new Font(FONT_NAME, Font.PLAIN, 12));
+
+ BarRenderer barRenderer = new BarRenderer();
+ barRenderer.setDefaultItemLabelsVisible(chart.isShowValues());
+ barRenderer.setDefaultItemLabelGenerator(
+ new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##")));
+ barRenderer.setDefaultItemLabelFont(new Font(FONT_NAME, Font.PLAIN, 10));
+
+ LineAndShapeRenderer lineRenderer = new LineAndShapeRenderer();
+ lineRenderer.setDefaultShapesVisible(true);
+ lineRenderer.setDefaultShapesFilled(true);
+ lineRenderer.setDefaultStroke(new BasicStroke(2.0f));
+ lineRenderer.setDefaultItemLabelsVisible(chart.isShowValues());
+ lineRenderer.setDefaultItemLabelGenerator(
+ new StandardCategoryItemLabelGenerator("{2}", new DecimalFormat("0.##")));
+ lineRenderer.setDefaultItemLabelFont(new Font(FONT_NAME, Font.PLAIN, 10));
+
+ CategoryPlot plot = new CategoryPlot(barDataset, domainAxis, leftAxis, barRenderer);
+ plot.setDataset(1, lineDataset);
+ plot.setRenderer(1, lineRenderer);
+ plot.setRangeAxis(1, rightAxis);
+ plot.mapDatasetToRangeAxis(0, 0);
+ plot.mapDatasetToRangeAxis(1, 1);
+ plot.setDatasetRenderingOrder(DatasetRenderingOrder.FORWARD);
+ plot.setBackgroundPaint(Color.WHITE);
+ plot.setDomainGridlinesVisible(true);
+ plot.setRangeGridlinesVisible(true);
+ plot.setAxisOffset(new RectangleInsets(5.0, 5.0, 5.0, 5.0));
+ plot.setDomainGridlinePaint(Color.LIGHT_GRAY);
+ plot.setRangeGridlinePaint(Color.LIGHT_GRAY);
+
+ return new JFreeChart(chart.getTitle(), JFreeChart.DEFAULT_TITLE_FONT, plot, true);
+ }
+
+ private static DefaultCategoryDataset buildDataset(WordChartVo chart) {
+ DefaultCategoryDataset dataset = new DefaultCategoryDataset();
+ List categories = chart.getCategories();
+ if (categories == null || categories.isEmpty()) {
+ return dataset;
+ }
+ for (WordSeriesVo series : chart.getSeries()) {
+ if (series == null || series.getName() == null || series.getValues() == null) {
+ continue;
+ }
+ for (int i = 0; i < categories.size(); i++) {
+ Double value = i < series.getValues().size() ? series.getValues().get(i) : null;
+ dataset.addValue(value == null ? 0.0 : value, series.getName(), categories.get(i));
+ }
+ }
+ return dataset;
+ }
+
+ /**
+ * 将数据按列(分类)归一化为百分比,用于归一化堆叠柱状图
+ */
+ private static DefaultCategoryDataset normalizePercent(DefaultCategoryDataset source) {
+ DefaultCategoryDataset result = new DefaultCategoryDataset();
+ int rowCount = source.getRowCount();
+ int columnCount = source.getColumnCount();
+ if (rowCount == 0 || columnCount == 0) {
+ return result;
+ }
+ for (int col = 0; col < columnCount; col++) {
+ double sum = 0.0;
+ for (int row = 0; row < rowCount; row++) {
+ Number value = source.getValue(row, col);
+ sum += value == null ? 0.0 : value.doubleValue();
+ }
+ for (int row = 0; row < rowCount; row++) {
+ Number value = source.getValue(row, col);
+ double v = value == null ? 0.0 : value.doubleValue();
+ double percent = sum == 0.0 ? 0.0 : v / sum * 100.0;
+ result.addValue(percent, source.getRowKey(row), source.getColumnKey(col));
+ }
+ }
+ return result;
+ }
+
+ private static void stylePlot(CategoryPlot plot) {
+ plot.setBackgroundPaint(Color.WHITE);
+ plot.setDomainGridlinesVisible(true);
+ plot.setRangeGridlinesVisible(true);
+ plot.setDomainGridlinePaint(Color.LIGHT_GRAY);
+ plot.setRangeGridlinePaint(Color.LIGHT_GRAY);
+ plot.setAxisOffset(new RectangleInsets(5.0, 5.0, 5.0, 5.0));
+ CategoryAxis domainAxis = plot.getDomainAxis();
+ domainAxis.setTickLabelFont(new Font(FONT_NAME, Font.PLAIN, 12));
+ NumberAxis rangeAxis = (NumberAxis) plot.getRangeAxis();
+ rangeAxis.setTickLabelFont(new Font(FONT_NAME, Font.PLAIN, 12));
+ }
+}
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
new file mode 100644
index 00000000..e7df7274
--- /dev/null
+++ b/backend/src/main/java/com/yfd/platform/qgc_export/util/WordChartExportUtil.java
@@ -0,0 +1,103 @@
+package com.yfd.platform.qgc_export.util;
+
+import com.yfd.platform.qgc_export.domain.word.WordChartExportRequest;
+import com.yfd.platform.qgc_export.domain.word.WordChartVo;
+import org.apache.poi.util.Units;
+import org.apache.poi.xwpf.usermodel.Document;
+import org.apache.poi.xwpf.usermodel.ParagraphAlignment;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFRun;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Word 图表导出工具:将多个图表渲染为图片并按顺序写入 .docx 文档。
+ */
+public final class WordChartExportUtil {
+
+ /** 插入图片的目标宽度(单位:磅,1 磅 = 1/72 英寸) */
+ private static final double IMAGE_WIDTH_PT = 460.0;
+
+ private WordChartExportUtil() {
+ }
+
+ /**
+ * 生成 Word 文档字节数组
+ *
+ * @param request 文档标题 + 图表列表
+ * @return .docx 字节数组
+ */
+ public static byte[] generateWord(WordChartExportRequest request) throws IOException {
+ List charts = request == null ? List.of() : request.getCharts();
+ try (XWPFDocument document = new XWPFDocument();
+ ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+
+ appendTitle(document, request);
+ for (WordChartVo chart : charts) {
+ appendChart(document, chart);
+ }
+ document.write(out);
+ return out.toByteArray();
+ }
+ }
+
+ private static void appendTitle(XWPFDocument document, WordChartExportRequest request) {
+ String title = request == null ? null : request.getTitle();
+ if (title != null && !title.isBlank()) {
+ XWPFParagraph paragraph = document.createParagraph();
+ paragraph.setAlignment(ParagraphAlignment.CENTER);
+ XWPFRun run = paragraph.createRun();
+ run.setText(title);
+ run.setBold(true);
+ run.setFontSize(22);
+ run.setFontFamily("SimSun");
+ }
+ String subtitle = request == null ? null : request.getSubtitle();
+ if (subtitle != null && !subtitle.isBlank()) {
+ XWPFParagraph paragraph = document.createParagraph();
+ paragraph.setAlignment(ParagraphAlignment.CENTER);
+ XWPFRun run = paragraph.createRun();
+ run.setText(subtitle);
+ run.setFontSize(12);
+ run.setColor("808080");
+ run.setFontFamily("SimSun");
+ }
+ }
+
+ private static void appendChart(XWPFDocument document, WordChartVo chart) throws IOException {
+ BufferedImage image = ChartRenderUtil.render(chart);
+ if (image == null) {
+ return;
+ }
+ byte[] png = toPngBytes(image);
+ double imageHeightPt = IMAGE_WIDTH_PT * image.getHeight() / image.getWidth();
+
+ XWPFParagraph paragraph = document.createParagraph();
+ paragraph.setAlignment(ParagraphAlignment.CENTER);
+ XWPFRun run = paragraph.createRun();
+ try (ByteArrayInputStream in = new ByteArrayInputStream(png)) {
+ try {
+ run.addPicture(in, Document.PICTURE_TYPE_PNG, "chart.png",
+ Units.toEMU(IMAGE_WIDTH_PT), Units.toEMU(imageHeightPt));
+ } catch (org.apache.poi.openxml4j.exceptions.InvalidFormatException e) {
+ throw new IOException("插入图片失败", e);
+ }
+ }
+ // 每个图表后空一行,避免图片粘连
+ XWPFParagraph spacer = document.createParagraph();
+ spacer.setSpacingAfter(120);
+ }
+
+ private static byte[] toPngBytes(BufferedImage image) throws IOException {
+ try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ ImageIO.write(image, "png", out);
+ return out.toByteArray();
+ }
+ }
+}