feat(ruoyi-common): 添加通用 Word 模板导出组件ruoyi-common-word

- 新增 ruoyi-common-word 模块,封装 poi-tl 组件以支持 Word 模板导出
- 在 ruoyi-common-bom 中添加 ruoyi-common-word 依赖管理
- 在 ruoyi-common 模块中引入 ruoyi-common-word 子模块
- 在顶层 pom.xml 中添加 poi-tl 版本属性
- ruoyi-common-word 模块依赖 poi-tl、ruoyi-common-core、Spring Boot 及 Servlet API
- 提供 WordTemplateUtil 工具类,支持模板渲染和多种输出方式
- 编写详细文档说明模块设计、数据结构和使用示例
- 规范模板存放路径,便于业务模块复用模板导出能力
dev
zangch@mesnac.com 6 days ago
parent 8eb5adcf2b
commit e6b4dbf55e

@ -58,6 +58,8 @@
<rocketmq.version>2.3.4</rocketmq.version>
<!-- MyBatis-Plus-Join -->
<mybatis-pj.version>1.5.4</mybatis-pj.version>
<!-- Word 模板导出 -->
<poi-tl.version>1.12.1</poi-tl.version>
<!-- 插件版本 -->
<maven-compiler-plugin.version>3.14.0</maven-compiler-plugin.version>

@ -45,6 +45,7 @@
<module>ruoyi-common-bus</module>
<module>ruoyi-common-sse</module>
<module>hwbm-common-workflow</module>
<module>ruoyi-common-word</module>
</modules>
<artifactId>ruoyi-common</artifactId>

@ -250,6 +250,13 @@
<version>${revision}</version>
</dependency>
<!-- Word模板导出 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-word</artifactId>
<version>${revision}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>ruoyi-common-word</artifactId>
<name>ruoyi-common-word</name>
<description>通用 Word 模板导出组件(封装 poi-tl</description>
<dependencies>
<!-- Word 模板引擎 -->
<dependency>
<groupId>com.deepoove</groupId>
<artifactId>poi-tl</artifactId>
<version>${poi-tl.version}</version>
</dependency>
<!-- POI 依赖由 poi-tl 传递引入,如需锁定版本可在顶层 dependencyManagement 中统一管理 -->
<!-- 公共核心工具依赖 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-core</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- Servlet API (仅编译期) -->
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>

@ -0,0 +1,242 @@
package org.dromara.common.word.util;
import com.deepoove.poi.XWPFTemplate;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.file.FileUtils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
/**
* Word poi-tl
*
*
* 1)
* 2) resources templates classpath
* {@code templates/wms_shipping_bill.docx}
* 3) Map
* - data.put("shippingCode", "X123"); 使 {{shippingCode}}
* - data.put("details", detailList); 使 {{#details}}...{{/details}}
* 4) Service dataController / /
*
*
* 使Web
* <pre>
* Map<String, Object> data = new HashMap<>();
* data.put("shippingCode", "X123");
* data.put("details", detailList); // 列表占位符示例
* WordTemplateUtil.renderToResponse("templates/wms_shipping_bill.docx", "发货单_X123", data, response);
* </pre>
*
*
* hwbm-cloud/ruoyi-common/ruoyi-common-word/word.md
*/
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class WordTemplateUtil {
/**
* HttpServletResponse Controller
*
* @param templatePath classpath "templates/wms_shipping_bill.docx"
* @param fileName "发货单_X123" .docx
* @param data Map
* @param response HttpServletResponse Spring MVC
*
*
* 1) response response
* 2) templatePath 使 classpath resources
*/
public static void renderToResponse(String templatePath, String fileName, Map<String, Object> data, HttpServletResponse response) {
Objects.requireNonNull(response, "HttpServletResponse must not be null");
if (StringUtils.isBlank(templatePath)) {
throw new ServiceException("Word 模板路径不能为空");
}
// 兜底处理:下载文件名为空时使用默认值;模板数据为空时使用空 Map避免 NPE
String safeFileName = StringUtils.isBlank(fileName) ? "export" : fileName.trim();
Map<String, Object> safeData = (data == null ? Collections.emptyMap() : data);
try (XWPFTemplate template = buildTemplate(templatePath, safeData);
OutputStream os = resetResponse(safeFileName, response)) {
template.write(os);
os.flush();
} catch (IOException e) {
log.error("Word 模板导出失败, templatePath={}, fileName={}", templatePath, safeFileName, e);
throw new ServiceException("Word 模板导出失败,请联系管理员").setDetailMessage(e.getMessage());
}
}
/**
* classpath
*
* @param templatePath classpath
* @return
*/
private static InputStream getTemplateStream(String templatePath) throws IOException {
// 使用当前线程的 ClassLoader 查找资源,兼容不同部署环境(应用服务器 / Spring Boot 可执行 Jar 等)
InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(templatePath);
if (is == null) {
throw new IOException("未找到模板文件: " + templatePath);
}
return is;
}
/**
*
*
* @param templatePath classpath
* @param data Map
* @return XWPFTemplate
*/
private static XWPFTemplate buildTemplate(String templatePath, Map<String, Object> data) throws IOException {
try (InputStream templateStream = getTemplateStream(templatePath)) {
return XWPFTemplate.compile(templateStream).render(data);
}
}
/**
*
*
* @param fileName .docx
* @param response HttpServletResponse
* @return Content-Type Content-Disposition
*/
private static OutputStream resetResponse(String fileName, HttpServletResponse response) throws IOException {
String realName = fileName;
if (!realName.endsWith(".docx")) {
realName = realName + ".docx";
}
FileUtils.setAttachmentResponseHeader(response, realName);
response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document;charset=UTF-8");
return response.getOutputStream();
}
/**
* OutputStream out
* HTTP
* <ul>
* <li> OSS / MinIO </li>
* <li> MQ </li>
* <li></li>
* </ul>
*
* @param templatePath classpath
* @param data Map
* @param out HttpServletResponse#getOutputStream
* FileOutputStreamByteArrayOutputStream
*/
public static void renderToOutputStream(String templatePath, Map<String, Object> data, OutputStream out) {
Objects.requireNonNull(out, "OutputStream must not be null");
if (StringUtils.isBlank(templatePath)) {
throw new ServiceException("Word 模板路径不能为空");
}
Map<String, Object> safeData = (data == null ? Collections.emptyMap() : data);
try (XWPFTemplate template = buildTemplate(templatePath, safeData)) {
template.write(out);
out.flush();
} catch (IOException e) {
log.error("Word 模板渲染失败, templatePath={}", templatePath, e);
throw new ServiceException("Word 模板渲染失败,请联系管理员").setDetailMessage(e.getMessage());
}
}
/**
* 便 Web
* <p>
* 使
* <ul>
* <li> OSS </li>
* <li> API </li>
* <li></li>
* </ul>
*
* @param templatePath classpath
* @param data Map
* @return Word
*/
public static byte[] renderToBytes(String templatePath, Map<String, Object> data) {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
renderToOutputStream(templatePath, data, bos);
return bos.toByteArray();
}
// =====================================================================
// 典型调用示例(仅作为文档说明,不参与编译):
// =====================================================================
/*
* 1. Controller Word仿
*
* @GetMapping("/exportShippingBill/{id}")
* public void exportShippingBill(@PathVariable Long id, HttpServletResponse response) {
* // 由业务 Service 组装模板所需数据 Map包含主表字段 + 明细列表等)
* Map<String, Object> data = wmsShippingBillService.buildWordExportData(id);
* String templatePath = "templates/wms_shipping_bill.docx"; // 视具体模块而定
* String fileName = "发货单_" + id; // 或使用业务单号
* WordTemplateUtil.renderToResponse(templatePath, fileName, data, response);
* }
*
* 2. Service OSS / MinIO Web
*
* public String generateAndUploadShippingBill(Long id) {
* Map<String, Object> data = wmsShippingBillService.buildWordExportData(id);
* byte[] bytes = WordTemplateUtil.renderToBytes("templates/wms_shipping_bill.docx", data);
* // 伪代码:上传到对象存储,返回访问地址
* String objectKey = "word/shippingBill/" + id + ".docx";
* return ossClient.upload(bytes, objectKey);
* }
*
* 3. /
*
* public void exportToLocalFile(Long id) throws IOException {
* Map<String, Object> data = wmsShippingBillService.buildWordExportData(id);
* try (OutputStream out = Files.newOutputStream(Paths.get("D:/temp/shipping_bill_" + id + ".docx"))) {
* WordTemplateUtil.renderToOutputStream("templates/wms_shipping_bill.docx", data, out);
* }
* }
*
* 4. + / /
*
* // 伪代码:组装发货明细行和合计字段
* List<Map<String, Object>> details = new ArrayList<>();
* BigDecimal totalQuantity = BigDecimal.ZERO;
* BigDecimal totalAmount = BigDecimal.ZERO;
* int seq = 1;
* for (WmsShippingDetailsVo item : itemsVo) {
* Map<String, Object> row = new HashMap<>();
* row.put("seq", String.valueOf(seq++)); // 行号 → {{seq}}
* row.put("materialName", item.getMaterialName()); // 物料名称 → {{materialName}}
* row.put("quantity", item.getShippingStockAmount()); // 数量 → {{quantity}}
* row.put("unit", item.getUnitName()); // 单位 → {{unit}}
* row.put("unitPrice", item.getUnitPrice()); // 单价 → {{unitPrice}}
* BigDecimal amount = item.getUnitPrice()
* .multiply(item.getShippingStockAmount());
* row.put("amount", amount); // 金额 → {{amount}}
* details.add(row);
*
* totalQuantity = totalQuantity.add(item.getShippingStockAmount());
* totalAmount = totalAmount.add(amount);
* }
* data.put("details", details); // 循环表格数据源 → {{#details}}...{{/details}}
* data.put("totalQuantity", totalQuantity.toPlainString()); // 合计数量 → {{totalQuantity}}
* data.put("totalAmount", totalAmount.toPlainString()); // 合计金额 → {{totalAmount}}
*
* // Word 模板表格示意(仅说明占位符,不代表真实表格语法):
* // ┌────┬────────┬──────┬────┬──────┬───────┐
* // │ 序号 │ 物料名称 │ 数量 │ 单位 │ 单价 │ 金额 │
* // ├────┼────────┼──────┼────┼──────┼───────┤
* // │{{seq}}│{{materialName}}│{{quantity}}│{{unit}}│{{unitPrice}}│{{amount}}│ ← 放在 {{#details}}...{{/details}} 内部
* // └────┴────────┴──────┴────┴──────┴───────┘
* // 合计:数量 {{totalQuantity}},金额 {{totalAmount}}
* */
}

@ -0,0 +1,219 @@
# poi-tl 与 ruoyi-common-word 模块说明
## 1. 模块定位ruoyi-common-word
- **模块路径**`hwbm-cloud/ruoyi-common/ruoyi-common-word`
- **主要职责**
- 基于 [poi-tl](https://github.com/Sayi/poi-tl) 提供统一的 **Word 模板渲染与导出能力**
- 仅负责模板加载、渲染、输出,不侵入具体业务字段和业务规则;
- 供各业务模块(合同、报价、发货单、项目收货/验收等)统一复用。
- **核心工具类**`org.dromara.common.word.util.WordTemplateUtil`
- `renderToResponse(...)`Controller 直接导出 Word
- `renderToOutputStream(...)`:向任意 `OutputStream` 写入 WordOSS、本地文件等
- `renderToBytes(...)`:返回字节数组,便于后续上传或作为接口返回值。
> 详细 JavaDoc 和更多示例可参考 `WordTemplateUtil` 源码本身。
---
## 2. poi-tl 简介
poi-tl 是构建在 Apache POI 之上的 **Word 模板引擎**,通过简单的占位符语法实现:
- **普通字段占位符**`{{fieldName}}`
- **列表循环占位符**
- 语法:`{{#list}} ... {{/list}}`
- `list` 对应数据 Map 中的 `List<Map<String, Object>>`
- 循环体内部使用 `{{列名}}` 访问每一行 Map 中的字段。
本项目只做了一层简单封装,不改变 poi-tl 原生语义,方便直接对照官方文档理解。
---
## 3. 模板文件存放规范
- 建议所有业务模块将 Word 模板统一放在各自模块的 `resources/templates` 目录下,例如:
- `hwbm-cloud/ruoyi-modules/ruoyi-wms/src/main/resources/templates/wms_shipping_bill.docx`
- 其他模块可类比:`templates/erp_contract.docx`、`templates/crm_quote.docx` 等。
- `WordTemplateUtil` 接收的是 **classpath 相对路径**,例如:
```java
WordTemplateUtil.renderToResponse("templates/wms_shipping_bill.docx", "发货单_X123", data, response);
```
部署后,只要模板文件在 Jar 的 `BOOT-INF/classes/templates` 下即可被正常加载。
---
## 4. 数据 Map 结构约定
`WordTemplateUtil` 不关心业务字段含义,只约定数据以 `Map<String, Object>` 形式传入:
- **普通字段**
```java
Map<String, Object> data = new HashMap<>();
data.put("customerName", "某某公司"); // {{customerName}}
data.put("shippingDate", "2025-12-10"); // {{shippingDate}}
data.put("contractCode", "HT-2025-001"); // {{contractCode}}
```
- **列表字段(循环表格)**
```java
List<Map<String, Object>> details = new ArrayList<>();
Map<String, Object> row1 = new HashMap<>();
row1.put("seq", 1); // {{seq}}
row1.put("materialName", "胶辊"); // {{materialName}}
row1.put("quantity", 10); // {{quantity}}
row1.put("unit", "件"); // {{unit}}
row1.put("remark", "加急发货"); // {{remark}}
details.add(row1);
data.put("details", details); // 对应模板 {{#details}}...{{/details}}
```
- **合计字段(可选)**
```java
data.put("totalQuantity", "100"); // {{totalQuantity}}
data.put("totalAmount", "12345.67"); // {{totalAmount}}
```
模板与数据字段的对应关系完全由业务模块自己约定,但**应在各自模块文档中说明**,便于维护。
---
## 5. 发货单WmsShippingBill标准示例
发货单模块(`ruoyi-wms`)是 `WordTemplateUtil` 的一个完整应用示例,推荐其他单据类模块(合同、报价、项目收货/验收等)参照其设计。
### 5.1 Controller 入口示例
文件:`WmsShippingBillController.exportWord`
```java
@GetMapping("/exportWord/{shippingBillId}")
public void exportWord(@PathVariable Long shippingBillId, HttpServletResponse response) {
// 1由 Service 组装模板数据 Map包含主表字段 + 明细列表)
Map<String, Object> data = wmsShippingBillService.buildWordExportData(shippingBillId);
// 2生成下载文件名优先使用发货单号
WmsShippingBillVo vo = wmsShippingBillService.queryById(shippingBillId);
String fileName = "发货单_" + (vo != null && vo.getShippingCode() != null
? vo.getShippingCode() : shippingBillId);
// 3调用通用工具类完成渲染与输出
WordTemplateUtil.renderToResponse("templates/wms_shipping_bill.docx", fileName, data, response);
}
```
### 5.2 Service 组装数据示例
文件:`WmsShippingBillServiceImpl.buildWordExportData`
核心思路:
1. 查询发货单主表 + 明细列表 VO
2. 组装主表字段(顶部信息、落款信息);
3. 组装明细列表 `details`(循环表格数据源)。
部分代码示意:
```java
public Map<String, Object> buildWordExportData(Long shippingBillId) {
WmsShippingBillVo vo = queryById(shippingBillId);
if (vo == null) {
throw new ServiceException("发货单不存在ID" + shippingBillId);
}
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
Map<String, Object> data = new HashMap<>();
// === 主表字段 ===
data.put("customerName", StringUtils.blankToDefault(vo.getCustomerName(), "")); // {{customerName}}
data.put("receiverName", StringUtils.blankToDefault(vo.getReceiverName(), "")); // {{receiverName}}
data.put("receiverPhone", StringUtils.blankToDefault(vo.getReceiverPhone(), "")); // {{receiverPhone}}
data.put("shippingDate", vo.getShippingTime() != null ? sdf.format(vo.getShippingTime()) : ""); // {{shippingDate}}
data.put("contractCode", StringUtils.blankToDefault(vo.getContractCode(), "")); // {{contractCode}}
data.put("receivedDate", ""); // 收货日期留空,由客户签收时手工填写 → {{receivedDate}}
// 落款信息(供应商 / 发货方)
data.put("shippingUnit", StringUtils.blankToDefault(vo.getSupplier(), "")); // {{shippingUnit}}
data.put("shipper", StringUtils.blankToDefault(vo.getContactUser(), "")); // {{shipper}}
data.put("contactNumber", StringUtils.blankToDefault(vo.getContactNumber(), "")); // {{contactNumber}}
// === 明细列表,对应 {{#details}}...{{/details}} ===
List<Map<String, Object>> details = new ArrayList<>();
if (CollUtil.isNotEmpty(vo.getItemsVo())) {
int seq = 1;
for (WmsShippingDetailsVo item : vo.getItemsVo()) {
Map<String, Object> row = new HashMap<>();
row.put("seq", String.valueOf(seq++)); // {{seq}}
row.put("materialName", StringUtils.blankToDefault(item.getMaterialName(), "")); // {{materialName}}
row.put("quantity", item.getShippingStockAmount() != null
? item.getShippingStockAmount().toPlainString() : ""); // {{quantity}}
row.put("unit", StringUtils.blankToDefault(item.getUnitName(), "")); // {{unit}}
row.put("remark", StringUtils.blankToDefault(item.getRemark(), "")); // {{remark}}
details.add(row);
}
}
data.put("details", details);
return data;
}
```
### 5.3 发货单模板占位符示意
> 下面仅用伪代码示意占位符位置,实际模板在 Word 中通过表格 / 段落排版完成。
```text
客户名称:{{customerName}}
联系人:{{receiverName}} 联系电话:{{receiverPhone}}
发货日期:{{shippingDate}} 合同编号:{{contractCode}}
{{#details}}
序号:{{seq}} 物料:{{materialName}} 数量:{{quantity}} 单位:{{unit}} 备注:{{remark}}
{{/details}}
收货日期:{{receivedDate}}
发货单位:{{shippingUnit}}
发货人:{{shipper}} 联系电话:{{contactNumber}}
```
> 其它单据(如报价、合同、项目收货/验收)可以直接复用上述模式:
> - 主表字段:顶部信息 + 落款信息;
> - 明细字段:`details` 循环表格;
> - 可扩展合计字段:`totalQuantity`、`totalAmount` 等。
---
## 6. 常见使用场景总结
1. **Web 端直接下载 Word**
- Controller 调用 `WordTemplateUtil.renderToResponse(...)`
- 浏览器收到 `Content-Disposition: attachment` 头,触发下载。
2. **上传到 OSS / MinIO 等对象存储**
- 使用 `renderToBytes(...)` 生成字节数组;
- 由 OSS 客户端统一上传并返回访问 URL。
3. **定时任务或后台服务批量生成 Word**
- 使用 `renderToOutputStream(...)` 写入本地文件或网络流;
- 后续可再做压缩、归档、推送等操作。
4. **集成测试 / 模板验收**
- 使用 `renderToBytes(...)` 生成文档;
- 对生成内容进行人工或自动化检查(例如比对关键字段是否渲染正确)。
---
## 7. 推荐阅读顺序
1. 阅读本 `word.md` 文档,理解整体设计与发货单示例;
2. 打开 `WordTemplateUtil` 源码,查看 JavaDoc 中的详细注释和示例;
3. 在业务模块中设计自己的模板(`.docx`)和 `buildXXXExportData` 方法,与本示例保持同样的模式;
4. 将新模块的模板字段 ↔ 数据字段映射关系同步更新到各自模块的业务文档中(例如 `hwbm.md`)。
Loading…
Cancel
Save