From fef4796256a7ab3e2d05c99a981f3fc78d42675a Mon Sep 17 00:00:00 2001 From: wangguangyuan Date: Fri, 11 Sep 2026 13:39:17 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8A=A8=E7=8E=AF=E5=B7=A1=E6=A3=80?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E6=8E=92=E7=89=88=E5=AF=B9=E9=BD=90=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E4=BA=BA=E5=B7=A1=E6=A3=80=E6=8A=A5=E5=91=8A=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inspect/base/core/sftp/SftpClient.java | 2 +- .../api/service/ReportUploadService.java | 6 +- .../DonghuanReportExportController.java | 899 ++++++++++++++---- .../eqpbook/BasedataEqpBookChannelMapper.xml | 2 + 4 files changed, 700 insertions(+), 209 deletions(-) diff --git a/inspect-base/inspect-base-core/src/main/java/com/inspect/base/core/sftp/SftpClient.java b/inspect-base/inspect-base-core/src/main/java/com/inspect/base/core/sftp/SftpClient.java index b6ca882..ae198b9 100644 --- a/inspect-base/inspect-base-core/src/main/java/com/inspect/base/core/sftp/SftpClient.java +++ b/inspect-base/inspect-base-core/src/main/java/com/inspect/base/core/sftp/SftpClient.java @@ -113,7 +113,7 @@ public class SftpClient { ftps.setFileType(FTPSClient.BINARY_FILE_TYPE); sftpFactory.init(); } - + ftps.setControlEncoding("UTF-8"); ftps.enterLocalPassiveMode(); ftps.enterLocalPassiveMode(); ftps.setFileTransferMode(FTPSClient.STREAM_TRANSFER_MODE); diff --git a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadService.java b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadService.java index f34f5de..f02fa4d 100644 --- a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadService.java +++ b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadService.java @@ -15,6 +15,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URL; +import java.nio.charset.StandardCharsets; /** * 巡检报告上传服务(外部平台《巡检报告接口文档 v1.0.0》) @@ -127,8 +128,9 @@ public class ReportUploadService { out.write(reportInfoJson.getBytes("UTF-8")); out.writeBytes("\r\n"); // file 字段 - out.writeBytes("--" + boundary + "\r\n"); - out.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"\r\n"); + out.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8)); + String fileHeader = "Content-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"\r\n"; + out.write(fileHeader.getBytes(StandardCharsets.UTF_8)); out.writeBytes("Content-Type: application/octet-stream\r\n\r\n"); try (InputStream in = new ByteArrayInputStream(fileBytes)) { byte[] buf = new byte[8192]; diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/DonghuanReportExportController.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/DonghuanReportExportController.java index b0b89f4..fb570a6 100644 --- a/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/DonghuanReportExportController.java +++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/DonghuanReportExportController.java @@ -59,11 +59,31 @@ import java.util.zip.ZipOutputStream; *
  • 页面:A4(11906×16838 twips),版心 上2098/右1474/下1984/左1587
  • *
  • 封面:42pt 仿宋_GB2312 大标题 + 分页;第二页 logo + 方正小标宋 22pt 深蓝标题(不加粗) * + 仿宋_GB2312 16pt 浅蓝副标题 + 封面签署表(初审/校对/审核/巡检日期)
  • - *
  • 标题:报告标题 方正小标宋简体 22pt(二号)不加粗;一级标题(一、…):黑体 16pt(三号); - * 二级标题((一)…):仿宋_GB2312 16pt(三号);表头/栏头:深蓝 1F4E79 底白字加粗仿宋_GB2312 14pt(四号); - * 表格正文:仿宋_GB2312 12pt(小四)居中
  • - *
  • 表格边框:B4C6E7 细线(sz=0),单元格垂直居中,表格文字居中、单倍行距,行高不低于 454 twips
  • - *
  • 巡检详情:每个点位一张 8 行 × 4 列详情表,末行基准图/缺陷图
  • + *
  • 标题:报告标题 方正小标宋简体 22pt(二号)不加粗;一级标题:黑体 16pt(三号); + * 二级标题:仿宋_GB2312 16pt(三号)
  • + *
  • 章节编号(与模板一致):标题使用 Word 多级自动编号 —— 一级「一、」、二级「(一)」、 + * 三级「1.」、四级「(1)」(见 {@link #createHeadingNumbering}),标题文字里不再写手写序号; + * 各级标题带 outlineLvl 大纲级别,Word 导航窗格与「引用 → 目录」可直接生成目录
  • + *
  • 表格宽度:通篇统一 {@link #TABLE_W} = 8844 twips(= 版心宽,与模板一致), + * 列宽由 {@code COLS_*} 常量按总和 8844 分配,tblLayout=fixed、表格居中, + * 保证所有表格左右边界完全对齐且不越出版心
  • + *
  • 表头/栏头:深蓝 1F4E79 底 + 白字 + 加粗 + 四号 14pt + 居中; + * 「栏头」= 巡检详情表内用于标注取值的单元格(巡检点位名称 / 巡检时间 / 区域名称 / + * 基准图 / 缺陷图 等),与表头同款样式,便于与右侧取值区分
  • + *
  • 表格正文:仿宋_GB2312 12pt(小四)、居中、单元格垂直居中;行高不设最小值(与模板一致), + * 行高由「行距 + 上下边距」自然决定:实测模板单行数据行 = 312 + 120 = 432 twips; + * 若写 w:trHeight 最小值,WPS 会在该值之上再加边距,单行数据行会比模板高约 7 磅
  • + *
  • 单元格内边距与行距(按模板实测值对齐):表头/栏头 上下 211、左右 120、行高贴合文字(362), + * 折行时两行行首距 624(= 模板行网格 2 行);正文 上下 60、左右 120、行高 312(15.6 磅/行); + * 封面签署表 上下 60、左 150、右 120、行高固定 560(28 磅) + * —— 见 {@link #LAYOUT_HEAD} / {@link #LAYOUT_BODY} / {@link #LAYOUT_COVER}。 + * 模板的表头用「624 行框 + 上下 80」,文字贴行框顶、看起来偏上;本报告把行框收到贴合文字、 + * 差额补给内边距,行高与行首距与模板相同而文字真正垂直居中
  • + *
  • 文字换行:单元格文字按列宽计算换行 —— 表头按语义/均衡拆分(如「区域/名称」 + * 「巡检点位/名称」「巡检点位数量/(个)」),正文避免末行只剩 1 个字的孤字, + * 并遵守避头尾、不拆开英文数字串(见 {@link #wrapLines})
  • + *
  • 表格边框:B4C6E7 细线(sz=0),与模板 tcBorders 一致
  • + *
  • 巡检详情:每个点位一张 8 行 × 4 列详情表,末行基准图/缺陷图(图片按单元格内容宽等比缩放)
  • * *

    * 已知不确定点(待归档时调整,均以 TODO 标注): @@ -84,6 +104,17 @@ public class DonghuanReportExportController extends BaseController { private static final String FONT_HEAD = "黑体"; /** 报告标题 */ private static final String FONT_TITLE = "方正小标宋简体"; + /** + * 表头/栏头字体:黑体。 + *

    + * 为什么表头不用「仿宋_GB2312 加粗」:仿宋_GB2312 本身没有独立粗体字面,docx 里嵌入的 + * 「仿宋_GB2312 粗体」槽位({@code w:embedBold})指向的仍是同一份常规字体 + * (见 {@link ReportFontEmbedder}),Word 打开时不会再做加粗,表头看起来与正文一样细; + * 黑体嵌入的是真正粗重的字面(simhei),且加粗槽位指向它,因此表头统一使用 + * 黑体 + 加粗,在任何查看端都呈现为粗体表头(也与「一、二、三」等黑体小标题一致)。 + * 若要恢复「仿宋_GB2312 加粗」,只需把本常量改为 {@link #FONT_BODY}。 + */ + private static final String FONT_TABLE_HEAD = FONT_BODY; /** 深蓝:标题 / 表头底色 */ private static final String COLOR_DARK = "1F4E79"; /** 浅蓝:封面副标题 */ @@ -101,15 +132,132 @@ public class DonghuanReportExportController extends BaseController { private static final int SZ_HEAD_FOURTH = 28; /** 一级/二级标题 16pt(半磅 32)三号 */ private static final int SZ_HEAD_THIRD = 32; - /** 行高 atLeast(twips) */ - private static final int ROW_H = 454; - /** 单元格左右边距(twips) */ - private static final int CELL_MAR = 108; + /** + * 单元格左右内边距(twips):与模板 tcMar 一致(模板表头/正文均为 120)。 + * 注意 {@link #contentWidth} 用它算可用文字宽度,列宽分配与之配套。 + */ + private static final int CELL_MAR = 120; + /** + * 表头/栏头文字自身行高(twips):14pt 仿宋_GB2312 的行框高,WPS 实测 362(18.1 磅)。 + * 表头段落用它作为行高(atLeast,贴合文字),而不是直接用下面的 624。 + */ + private static final int HEAD_TEXT_LINE = 362; + /** + * 表头行网格高度(twips):模板文档网格 {@code linePitch=312},14pt 行 (>312) 被吸附到 + * 2 行网格 = 624(31.2 磅),实测模板「识别 / 类型」两行行首距 = 624。 + */ + private static final int CELL_LINE_HEAD = 624; + /** 表头两行之间的段后间距(twips)= 624 − 362:使两行行首距仍为模板的 624,文字块整体仍居中 */ + private static final int HEAD_LINE_GAP = CELL_LINE_HEAD - HEAD_TEXT_LINE; + /** + * 表头/栏头单元格上下内边距(twips)= 模板 80 + (624 − 362) / 2 = 211。 + *

    + * 模板的做法是「624 高的行框 + 上下 80 内边距」:行框远高于文字自身行高(362), + * 文字贴着行框顶部,于是实测模板表头文字上留白仅 85 twips、下留白 342 twips —— 明显偏上。 + * 本报告把行框收到贴合文字、把差额补给上下内边距,得到与模板相同的行高 + * (单行 790 vs 模板 789、两行 1415 vs 模板 1413、两行行首距 625 vs 模板 624), + * 同时文字上下留白对称(216 / 212),即真正垂直居中。 + */ + private static final int CELL_MAR_TB_HEAD = 80 + (CELL_LINE_HEAD - HEAD_TEXT_LINE) / 2; + /** 正文单元格上下内边距(twips):与模板实测一致(60) */ + private static final int CELL_MAR_TB_BODY = 60; + /** + * 正文单元格段落行高(twips):模板文档网格 {@code linePitch=312}(15.6 磅/行), + * 12pt 正文吸附 1 行网格 = 312(实测模板数据行高 312 + 上下边距 120 = 432 twips)。 + */ + private static final int CELL_LINE = 312; + /** 封面签署表单元格上下内边距(twips):模板实测 60 */ + private static final int COVER_MAR_TOP_BOTTOM = 60; + /** 封面签署表左侧内边距(twips):模板实测 150(左比右宽,是为签字留白) */ + private static final int COVER_MAR_LEFT = 150; + /** 封面签署表右侧内边距(twips):模板实测 120 */ + private static final int COVER_MAR_RIGHT = 120; + /** 封面签署表段落行高(twips):模板为固定值 560(28 磅),实测行高 560 + 上下边距 120 = 680 */ + private static final int COVER_CELL_LINE = 560; - /** 巡检详情图片尺寸(EMU,与模板一致) */ + /** + * 单元格排版参数(内边距 + 段落行高/规则 + 表头行间间距)。 + *

    + * 三套参数与机器人巡检报告模板逐一实测对齐:表头/栏头、正文数据、封面签署表。 + */ + private static final class CellLayout { + final int marTop, marBottom, marLeft, marRight, lineTw; + final STLineSpacingRule.Enum lineRule; + /** 多行单元格中,除最后一行外每行段后间距(twips):表头用它撑出模板的两行行首距 624 */ + final int lineGapTw; + + CellLayout(int marTop, int marBottom, int marLeft, int marRight, + int lineTw, STLineSpacingRule.Enum lineRule, int lineGapTw) { + this.marTop = marTop; + this.marBottom = marBottom; + this.marLeft = marLeft; + this.marRight = marRight; + this.lineTw = lineTw; + this.lineRule = lineRule; + this.lineGapTw = lineGapTw; + } + } + + /** + * 表头/栏头:上下 211、左右 120,行高贴合文字(362),两行之间 262 → + * 单行行高 790 / 两行 1415 / 两行行首距 625,与模板实测(789 / 1413 / 624)一致,且文字垂直居中。 + */ + private static final CellLayout LAYOUT_HEAD = new CellLayout(CELL_MAR_TB_HEAD, CELL_MAR_TB_HEAD, + CELL_MAR, CELL_MAR, HEAD_TEXT_LINE, STLineSpacingRule.AT_LEAST, HEAD_LINE_GAP); + /** 正文数据:上下 60、左右 120,行高 312(模板实测) */ + private static final CellLayout LAYOUT_BODY = new CellLayout(CELL_MAR_TB_BODY, CELL_MAR_TB_BODY, + CELL_MAR, CELL_MAR, CELL_LINE, STLineSpacingRule.AT_LEAST, 0); + /** 封面签署表:上下 60、左 150、右 120,行高固定 560(模板实测) */ + private static final CellLayout LAYOUT_COVER = new CellLayout(COVER_MAR_TOP_BOTTOM, COVER_MAR_TOP_BOTTOM, + COVER_MAR_LEFT, COVER_MAR_RIGHT, COVER_CELL_LINE, STLineSpacingRule.EXACT, 0); + + // ---------------------------- 表格宽度(通篇统一) ---------------------------- + /** + * 正文表格统一宽度(twips):版心宽 = A4 11906 - 左 1587 - 右 1474 = 8845, + * 取 8844(与机器人巡检报告模板一致)。所有正文表格的 tblW 与 tblGrid 列宽之和 + * 都必须等于该值,保证通篇表格左右边界完全对齐、且不越出版心。 + */ + private static final int TABLE_W = 8844; + /** + * 封面签署表列宽(4 行 × 2 列,合计 4686,居中,与模板一致)。 + * 封面签署表是签名栏,不参与正文表格宽度统一。 + */ + private static final int[] COLS_COVER = {1800, 2886}; + /** 一、巡检基本情况(4 列):电站名称 | 值 | 位置 | 值(合计 8844) */ + private static final int[] COLS_BASIC = {1783, 2591, 1874, 2596}; + /** 二、巡检状态说明(3 列):状态 | 说明 | 典型场景(合计 8844) */ + private static final int[] COLS_STATUS = {1787, 4017, 3040}; + /** 三(一)巡检统计(2 列):巡检状态 | 数量(个)(合计 8844) */ + private static final int[] COLS_STAT = {4217, 4627}; + /** + * 三(二)缺陷汇总表(9 列): + * 序号 | 区域名称 | 间隔名称 | 设备名称 | 部件名称 | 巡检点位名称 | 巡检结果 | 状态 | 图片信息 + * (合计 8844。左右内边距 120×2 后仍保证:区域/间隔/设备/部件 列 12pt 每行 3 个汉字(数据少折行)、 + * 巡检点位名称 列 14pt 表头 4+2、其余窄列 14pt 表头 2 个字不卡边) + */ + private static final int[] COLS_SUMMARY = {840, 970, 970, 970, 970, 1400, 880, 840, 1004}; + /** 四、巡检详情(4 列):栏头 | 值 | 栏头 | 值(合计 8844) */ + private static final int[] COLS_DETAIL = {1714, 2781, 1627, 2722}; + + /** 1 twip = 1/20 磅 = 635 EMU */ + private static final long EMU_PER_TWIP = 635L; + + /** 巡检详情图片原始尺寸(EMU)——仅用于等比缩放取宽高比 */ private static final long IMG_PATROL_W = 1711325L, IMG_PATROL_H = 960755L; private static final long IMG_DEFECT_W = 1629410L, IMG_DEFECT_H = 916940L; + // ---------------------------- 换行(避头尾 / 均衡) ---------------------------- + /** 不能出现在行首的字符(避头尾规则):收尾标点、右括号等 */ + private static final String NO_LINE_START = ",。、;:?!)〕】》」』〉”’%‰℃·,.;:?!)]}"; + /** 不能出现在行尾的字符:开括号等 */ + private static final String NO_LINE_END = "(〔【《「『〈“‘([{"; + /** + * 「类别词」后缀:4 字表头按 2+2 拆分、6 字表头按 4+2 拆分, + * 让「巡检点位 / 名称」「识别 / 类型」这类拆分符合语义(与模板表头一致) + */ + private static final String[] TAIL_WORDS = {"名称", "结果", "状态", "类型", "标准", + "信息", "时间", "数量", "等级", "照片", "原图", "判定", "场景", "意见", "读数"}; + @Value("${sftp.basePath}") private String basePath; @@ -460,12 +608,14 @@ public class DonghuanReportExportController extends BaseController { // 嵌入报告字体(仿宋_GB2312/黑体/方正小标宋),docx 自带字体,查看方无需安装 reportFontEmbedder.embed(doc); setupPage(doc); + // 章节标题多级自动编号(与机器人模板一致:一、/(一)/1./(1)) + BigInteger headingNumId = createHeadingNumbering(doc); buildCoverPage1(doc); // 第1页:42pt 大标题 + 分页 buildCoverPage2(doc, report); // 第2页:logo + 报告标题 + 副标题 + 签署表 - buildSectionBasic(doc, report); // 一、巡检基本情况 - buildSectionStatus(doc); // 二、巡检状态说明 - buildSectionOverview(doc, report, tempFileMap); // 三、巡检结果概览(缺陷汇总含图片信息) - buildSectionDetail(doc, report, tempFileMap); // 四、巡检详情 + buildSectionBasic(doc, report, headingNumId); // 一、巡检基本情况 + buildSectionStatus(doc, headingNumId); // 二、巡检状态说明 + buildSectionOverview(doc, report, tempFileMap, headingNumId); // 三、巡检结果概览 + buildSectionDetail(doc, report, tempFileMap, headingNumId); // 四、巡检详情 return doc; } @@ -477,6 +627,87 @@ public class DonghuanReportExportController extends BaseController { } } + // --------------------------------------------------------------------------- + // 章节标题:多级自动编号 + 大纲级别(与机器人巡检报告模板一致) + // --------------------------------------------------------------------------- + + /** 章节标题编号级别:0=一级「一、」,1=二级「(一)」,2=三级「1.」,3=四级「(1)」 */ + private static final int LVL_1 = 0, LVL_2 = 1, LVL_3 = 2, LVL_4 = 3; + /** 各级编号文字(%n 为第 n 级序号占位符),与模板 numbering 定义一致 */ + private static final String[] HEADING_NUM_TEXT = {"%1、", "(%2)", "%3.", "(%4)"}; + /** 各级编号格式:一、/(一)用中文数字,1./(1)用阿拉伯数字(与模板一致) */ + private static final STNumberFormat.Enum[] HEADING_NUM_FMT = { + STNumberFormat.CHINESE_COUNTING, STNumberFormat.CHINESE_COUNTING, + STNumberFormat.DECIMAL, STNumberFormat.DECIMAL}; + /** 各级编号字体(模板:一级黑体,其余仿宋_GB2312) */ + private static final String[] HEADING_NUM_FONT = {FONT_HEAD, FONT_BODY, FONT_BODY, FONT_BODY}; + + /** + * 创建章节标题的多级自动编号定义(与机器人巡检报告模板一致),返回 numId。 + *

    + * 编号(一、/(一)/1./(1))由 Word 依据本定义自动生成,标题文字里不再写手写序号; + * 各级标题同时写入 outlineLvl(见 {@link #setHeadingNumbering}), + * Word 导航窗格与「引用 → 目录」可直接生成带页码的目录。 + */ + private BigInteger createHeadingNumbering(XWPFDocument doc) { + XWPFNumbering numbering = doc.createNumbering(); + CTAbstractNum ctAbstractNum = CTAbstractNum.Factory.newInstance(); + ctAbstractNum.setAbstractNumId(BigInteger.ZERO); + ctAbstractNum.addNewMultiLevelType().setVal(STMultiLevelType.MULTILEVEL); + for (int level = 0; level < HEADING_NUM_TEXT.length; level++) { + CTLvl lvl = ctAbstractNum.addNewLvl(); + lvl.setIlvl(BigInteger.valueOf(level)); + lvl.addNewStart().setVal(BigInteger.ONE); + lvl.addNewNumFmt().setVal(HEADING_NUM_FMT[level]); + lvl.addNewLvlText().setVal(HEADING_NUM_TEXT[level]); + lvl.addNewLvlJc().setVal(STJc.LEFT); + // 编号后不补制表符/空格(模板为 nothing),否则「一、」与标题之间会出现大段空隙 + lvl.addNewSuff().setVal(STLevelSuffix.NOTHING); + CTInd ind = lvl.addNewPPr().addNewInd(); + ind.setLeft(BigInteger.ZERO); + ind.setFirstLine(BigInteger.ZERO); + // 编号字符的字体字号(Word 优先取这里的设置,缺失时会退到正文样式而变小) + CTRPr numRPr = lvl.addNewRPr(); + CTFonts fonts = numRPr.addNewRFonts(); + String numFont = HEADING_NUM_FONT[level]; + fonts.setAscii(numFont); + fonts.setHAnsi(numFont); + fonts.setEastAsia(numFont); + fonts.setCs(numFont); + numRPr.addNewSz().setVal(BigInteger.valueOf(SZ_HEAD_THIRD)); + numRPr.addNewSzCs().setVal(BigInteger.valueOf(SZ_HEAD_THIRD)); + } + BigInteger abstractNumId = numbering.addAbstractNum(new XWPFAbstractNum(ctAbstractNum)); + return numbering.addNum(abstractNumId); + } + + /** + * 章节标题段落:多级自动编号 + 大纲级别;一级标题黑体三号,二级及以下仿宋_GB2312 三号(与模板一致)。 + * + * @param level 编号级别,取 {@link #LVL_1} / {@link #LVL_2} / {@link #LVL_3} / {@link #LVL_4} + */ + private XWPFParagraph addSectionHeading(XWPFDocument doc, String text, int level, + BigInteger numId, int before, int after) { + String font = (level == LVL_1) ? FONT_HEAD : FONT_BODY; + XWPFParagraph p = addPara(doc, text, font, SZ_HEAD_THIRD, false, false, null, null, before, after); + setHeadingNumbering(p, level, numId); + setFirstLineIndentOneChar(p, SZ_HEAD_THIRD); // 标题首行缩进 1 字符 + setKeepNext(p); + return p; + } + + /** 段落写入多级编号(numPr)与大纲级别(outlineLvl,供导航窗格/目录使用) */ + private void setHeadingNumbering(XWPFParagraph p, int level, BigInteger numId) { + CTPPr pPr = p.getCTP().isSetPPr() ? p.getCTP().getPPr() : p.getCTP().addNewPPr(); + CTNumPr numPr = pPr.isSetNumPr() ? pPr.getNumPr() : pPr.addNewNumPr(); + CTDecimalNumber ilvl = numPr.isSetIlvl() ? numPr.getIlvl() : numPr.addNewIlvl(); + ilvl.setVal(BigInteger.valueOf(level)); + CTDecimalNumber numIdNum = numPr.isSetNumId() ? numPr.getNumId() : numPr.addNewNumId(); + numIdNum.setVal(numId); + CTDecimalNumber outlineLvl = pPr.isSetOutlineLvl() ? pPr.getOutlineLvl() : pPr.addNewOutlineLvl(); + outlineLvl.setVal(BigInteger.valueOf(level)); + } + /** * 设置段落首行缩进 1 字符。 * 写 w:firstLineChars=100(Word/WPS 原生「首行缩进:1 字符」,随字号自动折算 1 个汉字宽), @@ -554,108 +785,100 @@ public class DonghuanReportExportController extends BaseController { COLOR_LIGHT, ParagraphAlignment.CENTER, 200, 100); // 空行(撑出版面,与模板一致) - for (int i = 0; i < 11; i++) { + // 行数按「签署表 4 行 × 680 twips(= 模板实测行高)刚好落在第 2 页、且第 2 页放不下 + // 「一、巡检基本情况」」标定:签署表行高对齐模板后变高,故由 11 行减为 9 行(实测可用 + // WPS 量出:末行仍在第 2 页、正文标题仍在第 3 页)。改这些参数前后务必量一下分页。 + for (int i = 0; i < 9; i++) { addPara(doc, "", null, SZ_BODY, false, false, null, ParagraphAlignment.CENTER, 200, 100); } addPara(doc, "", null, SZ_BODY, false, false, null, ParagraphAlignment.CENTER, 400, null); - // 封面签署表:初审/校对/审核/巡检日期(4行×2列,宽4686 twips) - XWPFTable table = addTable(doc, 4, 2, new int[]{1800, 2886}, 4686, true); + // 封面签署表:初审/校对/审核/巡检日期(4行×2列,宽4686 twips,居中,与模板一致) + XWPFTable table = addTable(doc, 4, COLS_COVER); String[] labels = {"初审", "校对", "审核", "巡检日期"}; // TODO 归档时调整:初审/校对/审核 人员数据来源暂用占位符;巡检日期取巡视开始日期 String[] values = {"[填写]", "[填写]", "[填写]", report.getInspectionStartTime() != null ? DateUtils.format(DateUtils.yyyyMMdd2, report.getInspectionStartTime()) : "[填写]"}; for (int i = 0; i < labels.length; i++) { + // 封面签署表不设最小行高:封面第二页的行数/空行是按版心精确排的,行高变化会把正文挤到封面页 setRow(table.getRow(i), null, true); setRowJcCenter(table.getRow(i)); - // 标签:仿宋_GB2312 12pt 加粗 深蓝 居中 - styleCell(table.getRow(i).getCell(0), labels[i], FONT_BODY, SZ_BODY, true, false, - COLOR_DARK, false, ParagraphAlignment.CENTER, 0, 0, 1800, null, - 60, 150, 60, 120); - // 值:仿宋_GB2312 12pt 灰色斜体 居中 - styleCell(table.getRow(i).getCell(1), values[i], FONT_BODY, SZ_BODY, false, true, - COLOR_GRAY, false, ParagraphAlignment.CENTER, 0, 0, 2886, null, - 60, 150, 60, 120); + // 标签:仿宋_GB2312 14pt 深蓝 居中,不加粗(与模板一致) + styleCell(table.getRow(i).getCell(0), singleLine(labels[i]), FONT_TABLE_HEAD, SZ_HEAD_FOURTH, + false, false, COLOR_DARK, false, ParagraphAlignment.CENTER, + COLS_COVER[0], null, LAYOUT_COVER); + // 值:仿宋_GB2312 14pt 灰色斜体 居中(模板为四号 14pt) + styleCell(table.getRow(i).getCell(1), singleLine(values[i]), FONT_BODY, SZ_HEAD_FOURTH, + false, true, COLOR_GRAY, false, ParagraphAlignment.CENTER, + COLS_COVER[1], null, LAYOUT_COVER); } } /** 一、巡检基本情况 */ - private void buildSectionBasic(XWPFDocument doc, InspectionReport report) { - XWPFParagraph h = addPara(doc, "一、巡检基本情况", FONT_HEAD, SZ_HEAD_THIRD, false, false, - null, null, 160, 80); - setKeepNext(h); - setFirstLineIndentOneChar(h, SZ_HEAD_THIRD); // 标题首行缩进 1 字符 + private void buildSectionBasic(XWPFDocument doc, InspectionReport report, BigInteger headingNumId) { + addSectionHeading(doc, "巡检基本情况", LVL_1, headingNumId, 160, 80); - XWPFTable t = addTable(doc, 7, 4, new int[]{1827, 2655, 1920, 2659}, 9061, false); + XWPFTable t = addTable(doc, 7, COLS_BASIC); List dataList = report.getReportDatalist(); // R0 表头(跨4列,深蓝底白字) - setRow(t.getRow(0), ROW_H, false); + setRow(t.getRow(0), null, true); mergeRowCells(t, 0, 0, 3); - styleCell(t.getRow(0).getCell(0), "场站基本信息", FONT_BODY, SZ_HEAD_FOURTH, true, false, - COLOR_WHITE, true, ParagraphAlignment.CENTER, 0, 0, 9061, 4, - 0, CELL_MAR, 0, CELL_MAR); + headerCell(t.getRow(0).getCell(0), "场站基本信息", TABLE_W, 4); // R1 电站名称 | 白杨坪 | 位置 | 四川省成都市 - setRow(t.getRow(1), ROW_H, false); - bodyCell(t.getRow(1).getCell(0), "电站名称", 1827); - bodyCell(t.getRow(1).getCell(1), val(report.getStationName()), 2655); - bodyCell(t.getRow(1).getCell(2), "位置", 1920); - bodyCell(t.getRow(1).getCell(3), val(report.getLocation()), 2659); + setRow(t.getRow(1), null, true); + bodyCell(t.getRow(1).getCell(0), "电站名称", COLS_BASIC[0]); + bodyCell(t.getRow(1).getCell(1), val(report.getStationName()), COLS_BASIC[1]); + bodyCell(t.getRow(1).getCell(2), "位置", COLS_BASIC[2]); + bodyCell(t.getRow(1).getCell(3), val(report.getLocation()), COLS_BASIC[3]); // ==================== R2:巡视任务基本信息(新增表头) ==================== - setRow(t.getRow(2), ROW_H, false); + setRow(t.getRow(2), null, true); mergeRowCells(t, 2, 0, 3); - styleCell(t.getRow(2).getCell(0), "巡视任务基本信息", FONT_BODY, SZ_HEAD_FOURTH, true, false, - COLOR_WHITE, true, ParagraphAlignment.CENTER, 0, 0, 9061, 4, - 0, CELL_MAR, 0, CELL_MAR); + headerCell(t.getRow(2).getCell(0), "任务基本信息", TABLE_W, 4); // R3 巡检任务 | xxx(跨3列) - setRow(t.getRow(3), ROW_H, false); - bodyCell(t.getRow(3).getCell(0), "巡检任务", 1827); + setRow(t.getRow(3), null, true); + bodyCell(t.getRow(3).getCell(0), "巡检任务", COLS_BASIC[0]); mergeRowCells(t, 3, 1, 3); - styleCell(t.getRow(3).getCell(1), val(report.getInspectionTaskName()), FONT_BODY, SZ_BODY, - false, false, null, false, ParagraphAlignment.CENTER, 0, 0, - 1827 + 2655 + 1920 + 2659 - 1827, 3, 0, CELL_MAR, 0, CELL_MAR); + bodyCell(t.getRow(3).getCell(1), val(report.getInspectionTaskName()), + sum(COLS_BASIC, 1, 3), 3); // R4 巡检开始时间 | xx | 巡检结束时间 | xx - setRow(t.getRow(4), ROW_H, false); - bodyCell(t.getRow(4).getCell(0), "巡检开始时间", 1827); - bodyCell(t.getRow(4).getCell(1), val(formatDateTime(report.getInspectionStartTime())), 2655); - bodyCell(t.getRow(4).getCell(2), "巡检结束时间", 1920); - bodyCell(t.getRow(4).getCell(3), val(formatDateTime(report.getInspectionEndTime())), 2659); + setRow(t.getRow(4), null, true); + bodyCell(t.getRow(4).getCell(0), "巡检开始时间", COLS_BASIC[0]); + bodyCell(t.getRow(4).getCell(1), val(formatDateTime(report.getInspectionStartTime())), COLS_BASIC[1]); + bodyCell(t.getRow(4).getCell(2), "巡检结束时间", COLS_BASIC[2]); + bodyCell(t.getRow(4).getCell(3), val(formatDateTime(report.getInspectionEndTime())), COLS_BASIC[3]); // R5 巡检点位数量(个) | n | 巡检设备数量(个) | m // TODO 归档时调整:数量口径(按点位名去重 / 按设备名去重) - setRow(t.getRow(5), ROW_H, false); - bodyCell(t.getRow(5).getCell(0), "巡检点位数量(个)", 1827); + setRow(t.getRow(5), null, true); + bodyCell(t.getRow(5).getCell(0), "巡检点位数量(个)", COLS_BASIC[0]); bodyCell(t.getRow(5).getCell(1), String.valueOf(distinctCount(dataList, - InspectionReportData::getPointName)), 2655); - bodyCell(t.getRow(5).getCell(2), "巡检设备数量(个)", 1920); + InspectionReportData::getPointName)), COLS_BASIC[1]); + bodyCell(t.getRow(5).getCell(2), "巡检设备数量(个)", COLS_BASIC[2]); bodyCell(t.getRow(5).getCell(3), String.valueOf(distinctCount(dataList, - InspectionReportData::getEqName)), 2659); + InspectionReportData::getEqName)), COLS_BASIC[3]); // R6 审核意见 | (跨3列,可空) // TODO 归档时调整:审核意见来源(暂用 report.description,可改为 checkPerson 或空) - setRow(t.getRow(6), ROW_H, false); - bodyCell(t.getRow(6).getCell(0), "审核意见", 1827); + setRow(t.getRow(6), null, true); + bodyCell(t.getRow(6).getCell(0), "审核意见", COLS_BASIC[0]); mergeRowCells(t, 6, 1, 3); - styleCell(t.getRow(6).getCell(1), val(report.getDescription()), FONT_BODY, SZ_BODY, - false, false, null, false, ParagraphAlignment.CENTER, 0, 0, - 1827 + 2655 + 1920 + 2659 - 1827, 3, 0, CELL_MAR, 0, CELL_MAR); + bodyCell(t.getRow(6).getCell(1), val(report.getDescription()), + sum(COLS_BASIC, 1, 3), 3); } /** 二、巡检状态说明(静态文案,与模板一致) */ - private void buildSectionStatus(XWPFDocument doc) { - XWPFParagraph h = addPara(doc, "二、巡检状态说明", FONT_HEAD, SZ_HEAD_THIRD, false, false, - null, null, 160, 80); - setKeepNext(h); - setFirstLineIndentOneChar(h, SZ_HEAD_THIRD); // 标题首行缩进 1 字符 + private void buildSectionStatus(XWPFDocument doc, BigInteger headingNumId) { + addSectionHeading(doc, "巡检状态说明", LVL_1, headingNumId, 160, 80); - XWPFTable t = addTable(doc, 4, 3, new int[]{1831, 4115, 3114}, 9060, false); + XWPFTable t = addTable(doc, 4, COLS_STATUS); String[][] rows = { {"状态", "说明", "典型场景"}, {"缺陷", "巡检结果表明设备存在异常状态或潜在运行风险", @@ -666,26 +889,23 @@ public class DonghuanReportExportController extends BaseController { "表计读数正常、温度正常、设备外观正常"}, }; for (int i = 0; i < rows.length; i++) { - setRow(t.getRow(i), ROW_H, false); + setRow(t.getRow(i), null, true); if (i == 0) { - headerCell(t.getRow(i).getCell(0), rows[i][0], 1831); - headerCell(t.getRow(i).getCell(1), rows[i][1], 4115); - headerCell(t.getRow(i).getCell(2), rows[i][2], 3114); + headerCell(t.getRow(i).getCell(0), rows[i][0], COLS_STATUS[0]); + headerCell(t.getRow(i).getCell(1), rows[i][1], COLS_STATUS[1]); + headerCell(t.getRow(i).getCell(2), rows[i][2], COLS_STATUS[2]); } else { - bodyCell(t.getRow(i).getCell(0), rows[i][0], 1831, true); - bodyCell(t.getRow(i).getCell(1), rows[i][1], 4115, false); - bodyCell(t.getRow(i).getCell(2), rows[i][2], 3114, false); + bodyCell(t.getRow(i).getCell(0), rows[i][0], COLS_STATUS[0]); + bodyCell(t.getRow(i).getCell(1), rows[i][1], COLS_STATUS[1]); + bodyCell(t.getRow(i).getCell(2), rows[i][2], COLS_STATUS[2]); } } } /** 三、巡检结果概览:(一)巡检统计 + (二)缺陷汇总表(含图片信息列) */ private void buildSectionOverview(XWPFDocument doc, InspectionReport report, - Map tempFileMap) { - XWPFParagraph h = addPara(doc, "三、巡检结果概览", FONT_HEAD, SZ_HEAD_THIRD, false, false, - null, null, 160, 80); - setKeepNext(h); - setFirstLineIndentOneChar(h, SZ_HEAD_THIRD); // 标题首行缩进 1 字符 + Map tempFileMap, BigInteger headingNumId) { + addSectionHeading(doc, "巡检结果概览", LVL_1, headingNumId, 160, 80); List dataList = report.getReportDatalist() != null ? report.getReportDatalist() : Collections.emptyList(); @@ -694,14 +914,11 @@ public class DonghuanReportExportController extends BaseController { String statusNormal = messageUtils.get("正常"); // (一)巡检统计 - XWPFParagraph h1 = addPara(doc, "(一)巡检统计", FONT_BODY, SZ_HEAD_THIRD, false, false, - null, null, 80, 40); - setKeepNext(h1); - setFirstLineIndentOneChar(h1, SZ_HEAD_THIRD); // 标题首行缩进 1 字符 - XWPFTable statTable = addTable(doc, 5, 2, new int[]{4296, 4713}, 9008, true); - setRow(statTable.getRow(0), ROW_H, false); - headerCell(statTable.getRow(0).getCell(0), "巡检状态", 4296); - headerCell(statTable.getRow(0).getCell(1), "数量(个)", 4713); + addSectionHeading(doc, "巡检统计", LVL_2, headingNumId, 80, 40); + XWPFTable statTable = addTable(doc, 5, COLS_STAT); + setRow(statTable.getRow(0), null, true); + headerCell(statTable.getRow(0).getCell(0), "巡检状态", COLS_STAT[0]); + headerCell(statTable.getRow(0).getCell(1), "数量(个)", COLS_STAT[1]); String[][] statRows = { {statusDefect, String.valueOf(countByStatus(dataList, statusDefect))}, {statusAbnormal, String.valueOf(countByStatus(dataList, statusAbnormal))}, @@ -709,70 +926,59 @@ public class DonghuanReportExportController extends BaseController { {"合计", String.valueOf(dataList.size())}, }; for (int i = 0; i < statRows.length; i++) { - setRow(statTable.getRow(i + 1), ROW_H, false); - bodyCell(statTable.getRow(i + 1).getCell(0), statRows[i][0], 4296, true); - bodyCell(statTable.getRow(i + 1).getCell(1), statRows[i][1], 4713, true); + setRow(statTable.getRow(i + 1), null, true); + bodyCell(statTable.getRow(i + 1).getCell(0), statRows[i][0], COLS_STAT[0]); + bodyCell(statTable.getRow(i + 1).getCell(1), statRows[i][1], COLS_STAT[1]); } // (二)缺陷汇总表 - XWPFParagraph h2 = addPara(doc, "(二)缺陷汇总表", FONT_BODY, SZ_HEAD_THIRD, false, false, - null, null, 80, 40); - setKeepNext(h2); - setFirstLineIndentOneChar(h2, SZ_HEAD_THIRD); // 标题首行缩进 1 字符 + addSectionHeading(doc, "缺陷汇总表", LVL_2, headingNumId, 80, 40); List defectList = dataList.stream() .filter(d -> statusDefect.equals(d.getPointStatus())) .collect(Collectors.toList()); // 按批注要求:异常汇总(缺陷汇总)列表增加「图片信息」列,展示异常点位图片 - int[] summaryWidths = {650, 1088, 1115, 1128, 1100, 1549, 896, 658, 850}; - int summaryTotalWidth = Arrays.stream(summaryWidths).sum(); + int summaryTotalWidth = sum(COLS_SUMMARY, 0, COLS_SUMMARY.length - 1); // 无缺陷数据时保留一行(合并整行输出文字说明),避免表格空白 int summaryRows = 1 + Math.max(defectList.size(), 1); - XWPFTable summaryTable = addTable(doc, summaryRows, 9, summaryWidths, summaryTotalWidth, false); + XWPFTable summaryTable = addTable(doc, summaryRows, COLS_SUMMARY); String[] summaryHeaders = {"序号", "区域名称", "间隔名称", "设备名称", "部件名称", - "巡检点位名称", "巡检", "状态", "图片信息"}; - setRow(summaryTable.getRow(0), ROW_H, true); + "巡检点位名称", "巡检结果", "状态", "图片信息"}; + setRow(summaryTable.getRow(0), null, true); for (int i = 0; i < summaryHeaders.length; i++) { - headerCell(summaryTable.getRow(0).getCell(i), summaryHeaders[i], summaryWidths[i]); + headerCell(summaryTable.getRow(0).getCell(i), summaryHeaders[i], COLS_SUMMARY[i]); } - // 「巡检 / 结果」表头换行 - XWPFParagraph headP = summaryTable.getRow(0).getCell(6).getParagraphs().get(0); - setRunFont(headP.createRun(), "结果", FONT_BODY, SZ_HEAD_FOURTH, true, false, COLOR_WHITE, true); int num = 1; // 图片信息列缩略图尺寸:宽 = 单元格内容宽(列宽 - 左右边距),高按缺陷图比例等比缩放 - long summaryImgW = (summaryWidths[8] - 2L * CELL_MAR) * 635L; + long summaryImgW = contentWidth(COLS_SUMMARY[8]) * EMU_PER_TWIP; long summaryImgH = summaryImgW * IMG_DEFECT_H / IMG_DEFECT_W; for (int ri = 0; ri < defectList.size(); ri++) { InspectionReportData d = defectList.get(ri); - setRow(summaryTable.getRow(ri + 1), ROW_H, true); + setRow(summaryTable.getRow(ri + 1), null, true); String[] vals = {String.valueOf(num++), val(d.getArea()), val(d.getTimeInterval()), val(d.getEqName()), val(d.getParts()), val(d.getPointName()), val(d.getInspectionResults()), val(d.getPointStatus())}; for (int ci = 0; ci < vals.length; ci++) { - bodyCell(summaryTable.getRow(ri + 1).getCell(ci), vals[ci], - summaryWidths[ci], false, 120, 120); + bodyCell(summaryTable.getRow(ri + 1).getCell(ci), vals[ci], COLS_SUMMARY[ci]); } // 图片信息:该异常点位图片(优先缺陷图,其次原图;均无则横杠占位) imageCell(summaryTable.getRow(ri + 1).getCell(8), - defectSummaryImagePath(d, tempFileMap), summaryImgW, summaryImgH, summaryWidths[8]); + defectSummaryImagePath(d, tempFileMap), summaryImgW, summaryImgH, COLS_SUMMARY[8]); } if (defectList.isEmpty()) { // 无缺陷数据时:整行合并输出文字说明(原占位横杠改为「本次巡检未发现缺陷项」) - setRow(summaryTable.getRow(1), ROW_H, true); + setRow(summaryTable.getRow(1), null, true); mergeRowCells(summaryTable, 1, 0, 8); styleCell(summaryTable.getRow(1).getCell(0), - messageUtils.get("本次巡检未发现缺陷项"), FONT_BODY, SZ_BODY, - false, false, null, false, ParagraphAlignment.CENTER, 120, 120, - summaryTotalWidth, 9, 0, CELL_MAR, 0, CELL_MAR); + singleLine(messageUtils.get("本次巡检未发现缺陷项")), FONT_BODY, SZ_BODY, + false, false, null, false, ParagraphAlignment.CENTER, + summaryTotalWidth, 9, LAYOUT_BODY); } } /** 四、巡检详情:每个点位一张 8 行 × 4 列详情表 */ private void buildSectionDetail(XWPFDocument doc, InspectionReport report, - Map tempFileMap) { - XWPFParagraph h = addPara(doc, "四、巡检详情", FONT_HEAD, SZ_HEAD_THIRD, false, false, - null, null, 160, 80); - setKeepNext(h); - setFirstLineIndentOneChar(h, SZ_HEAD_THIRD); // 标题首行缩进 1 字符 + Map tempFileMap, BigInteger headingNumId) { + addSectionHeading(doc, "巡检详情", LVL_1, headingNumId, 160, 80); List dataList = report.getReportDatalist() != null ? report.getReportDatalist().stream().filter(item -> messageUtils.get("缺陷").equals(item.getPointStatus())).collect(Collectors.toList()) : Collections.emptyList(); @@ -784,8 +990,12 @@ public class DonghuanReportExportController extends BaseController { return; } for (InspectionReportData d : dataList) { - XWPFTable t = addTable(doc, 8, 4, new int[]{1707, 2891, 1665, 2785}, 9048, false); - int[] grid = {1707, 2891, 1665, 2785}; + XWPFTable t = addTable(doc, 8, COLS_DETAIL); + + // 图片按「基准图/缺陷图」列的内容宽等比缩放(两张图取同一宽度,视觉一致且不撑破单元格) + long imgW = contentWidth(Math.min(COLS_DETAIL[1], COLS_DETAIL[3])) * EMU_PER_TWIP; + long patrolH = imgW * IMG_PATROL_H / IMG_PATROL_W; + long defectH = imgW * IMG_DEFECT_H / IMG_DEFECT_W; String[][] pairs = { {"巡检点位名称", val(d.getPointName()), "巡检时间", val(formatDateTime(d.getAcquisitionTime()))}, @@ -795,33 +1005,31 @@ public class DonghuanReportExportController extends BaseController { {"巡检读数", val(d.getInspectionValue()), "巡检结果", val(d.getInspectionResults())}, }; for (int ri = 0; ri < pairs.length; ri++) { - setRow(t.getRow(ri), ROW_H, false); - bodyCell(t.getRow(ri).getCell(0), pairs[ri][0], grid[0], false, 120, 120); - bodyCell(t.getRow(ri).getCell(1), pairs[ri][1], grid[1], false, 120, 120); - bodyCell(t.getRow(ri).getCell(2), pairs[ri][2], grid[2], false, 120, 120); - bodyCell(t.getRow(ri).getCell(3), pairs[ri][3], grid[3], false, 120, 120); + setRow(t.getRow(ri), null, true); + // 奇数/偶数列为栏头(title),取与缺陷汇总表表头相同的样式,便于与取值区分 + headerCell(t.getRow(ri).getCell(0), pairs[ri][0], COLS_DETAIL[0]); + bodyCell(t.getRow(ri).getCell(1), pairs[ri][1], COLS_DETAIL[1]); + headerCell(t.getRow(ri).getCell(2), pairs[ri][2], COLS_DETAIL[2]); + bodyCell(t.getRow(ri).getCell(3), pairs[ri][3], COLS_DETAIL[3]); } // R5 巡检状态 | 状态(跨3列) - setRow(t.getRow(5), ROW_H, false); - bodyCell(t.getRow(5).getCell(0), "巡检状态", grid[0], false, 120, 120); + setRow(t.getRow(5), null, true); + headerCell(t.getRow(5).getCell(0), "巡检状态", COLS_DETAIL[0]); mergeRowCells(t, 5, 1, 3); - styleCell(t.getRow(5).getCell(1), val(d.getPointStatus()), FONT_BODY, SZ_BODY, - false, false, null, false, ParagraphAlignment.CENTER, 120, 120, - grid[1] + grid[2] + grid[3], 3, 0, CELL_MAR, 0, CELL_MAR); + bodyCell(t.getRow(5).getCell(1), val(d.getPointStatus()), + sum(COLS_DETAIL, 1, 3), 3); // R6 巡检原图 | 图片文件名(跨3列) String imgName = firstImageName(d); - setRow(t.getRow(6), ROW_H, false); - bodyCell(t.getRow(6).getCell(0), "巡检原图", grid[0], false, 120, 120); + setRow(t.getRow(6), null, true); + headerCell(t.getRow(6).getCell(0), "巡检原图", COLS_DETAIL[0]); mergeRowCells(t, 6, 1, 3); - styleCell(t.getRow(6).getCell(1), val(imgName), FONT_BODY, SZ_BODY, - false, false, null, false, ParagraphAlignment.CENTER, 120, 120, - grid[1] + grid[2] + grid[3], 3, 0, CELL_MAR, 0, CELL_MAR); + bodyCell(t.getRow(6).getCell(1), val(imgName), sum(COLS_DETAIL, 1, 3), 3); // R7 基准图 | 巡检原图照片 | 缺陷图 | 缺陷图照片 // 图片列表为空或路径缺失时置 null,由 imageCell 输出横杠占位,避免空白/空指针 - setRow(t.getRow(7), ROW_H, false); + setRow(t.getRow(7), null, true); Path patrolPath = null; Path defectPath = null; if (d.getReportImgList() != null && !d.getReportImgList().isEmpty()) { @@ -829,12 +1037,10 @@ public class DonghuanReportExportController extends BaseController { patrolPath = tempFileMap.get(inspectionReportImg.getImageNormalUrlPath()); defectPath = tempFileMap.get(inspectionReportImg.getImg()); } - bodyCell(t.getRow(7).getCell(0), "基准图", grid[0], false, 120, 120); - imageCell(t.getRow(7).getCell(1), patrolPath, - IMG_PATROL_W, IMG_PATROL_H, grid[1]); - bodyCell(t.getRow(7).getCell(2), "缺陷图", grid[2], false, 120, 120); - imageCell(t.getRow(7).getCell(3), defectPath, - IMG_DEFECT_W, IMG_DEFECT_H, grid[3]); + headerCell(t.getRow(7).getCell(0), "基准图", COLS_DETAIL[0]); + imageCell(t.getRow(7).getCell(1), patrolPath, imgW, patrolH, COLS_DETAIL[1]); + headerCell(t.getRow(7).getCell(2), "缺陷图", COLS_DETAIL[2]); + imageCell(t.getRow(7).getCell(3), defectPath, imgW, defectH, COLS_DETAIL[3]); // 表间间距 addPara(doc, "", null, SZ_BODY, false, false, null, null, 120, 120); @@ -868,17 +1074,14 @@ public class DonghuanReportExportController extends BaseController { spacing.setLineRule(STLineSpacingRule.AUTO); if (text != null && !text.isEmpty()) { XWPFRun run = p.createRun(); - setRunFont(run, text, font, halfPoints, bold, italic, color, false); + setRunFont(run, text, font, halfPoints, bold, italic, color); } return p; } /** 设置 run 字体(含中文 eastAsia)、字号、加粗、斜体、颜色 */ private void setRunFont(XWPFRun run, String text, String font, int halfPoints, - boolean bold, boolean italic, String color, boolean lineBreak) { - if (lineBreak) { - run.addBreak(); // 换行符在前,实现「巡检⏎结果」这类两行表头 - } + boolean bold, boolean italic, String color) { if (text != null) { run.setText(text); } @@ -906,10 +1109,20 @@ public class DonghuanReportExportController extends BaseController { } } - /** 建表:设置 tblW / 边框 / autofit / 网格列宽 */ - private XWPFTable addTable(XWPFDocument doc, int rows, int cols, int[] gridWidths, - int tblWidth, boolean center) { - XWPFTable table = doc.createTable(rows, cols); + /** + * 建表:设置 tblW / 边框 / 固定列宽 / 网格列宽。 + *

    + * tblW 由列宽之和推导(不再单独传参),tblLayout 固定 FIXED:列宽完全按 tblGrid 渲染, + * 不同渲染器(Word / WPS / LibreOffice)结果一致,进而保证「通篇表格宽度统一」。 + */ + private XWPFTable addTable(XWPFDocument doc, int rows, int[] gridWidths) { + int tblWidth = sum(gridWidths, 0, gridWidths.length - 1); + // 封面签署表(COLS_COVER)是签名栏,宽度与正文表格不同;其余表格必须等于版心宽 TABLE_W + if (tblWidth != TABLE_W && gridWidths != COLS_COVER) { + logger.warn("[TABLE] 表格宽度 {} 与版心宽度 {} 不一致,列宽={}", + tblWidth, TABLE_W, Arrays.toString(gridWidths)); + } + XWPFTable table = doc.createTable(rows, gridWidths.length); CTTbl tbl = table.getCTTbl(); CTTblPr tblPr = tbl.getTblPr(); if (tblPr == null) { @@ -919,16 +1132,15 @@ public class DonghuanReportExportController extends BaseController { tblW.setW(BigInteger.valueOf(tblWidth)); tblW.setType(STTblWidth.DXA); - if (center) { - CTJc jc = tblPr.isSetJc() ? tblPr.getJc() : tblPr.addNewJc(); - jc.setVal(STJc.CENTER); - } + // 表格水平居中(宽度等于版心宽时与左对齐视觉效果一致,封面签署表居中) + CTJc jc = tblPr.isSetJc() ? tblPr.getJc() : tblPr.addNewJc(); + jc.setVal(STJc.CENTER); CTTblBorders borders = tblPr.isSetTblBorders() ? tblPr.getTblBorders() : tblPr.addNewTblBorders(); setBorders(borders); CTTblLayoutType layout = tblPr.isSetTblLayout() ? tblPr.getTblLayout() : tblPr.addNewTblLayout(); - layout.setType(STTblLayoutType.AUTOFIT); + layout.setType(STTblLayoutType.FIXED); CTTblCellMar cellMar = tblPr.isSetTblCellMar() ? tblPr.getTblCellMar() : tblPr.addNewTblCellMar(); setCellMar(cellMar, 0, CELL_MAR, 0, CELL_MAR); @@ -1005,7 +1217,11 @@ public class DonghuanReportExportController extends BaseController { r.setType(STTblWidth.DXA); } - /** 行高(atLeast)+ 可选 cantSplit */ + /** + * 行高(可空 = 自动,与模板一致;行高由单元格行距 + 上下边距决定)+ 可选 cantSplit。 + * 注意:写 atLeast 最小值时 WPS 会把它当作「内容高度」,在其之上再加单元格上下边距, + * 单行数据行会比模板高约 7 磅,故正文表格一律不设最小行高。 + */ private void setRow(XWPFTableRow row, Integer height, boolean cantSplit) { // 旧版 poi-ooxml-schemas 中 w:tr 的类型是 CTRow(非 CTr), // 且 trPr 内元素为数组式访问器(sizeOfXxxArray/getXxxArray/addNewXxx) @@ -1042,37 +1258,51 @@ public class DonghuanReportExportController extends BaseController { } } - /** 表头单元格:深蓝底 + 白字 + 加粗 + 居中(仿宋_GB2312 四号 14pt,与模板表头一致) */ - private void headerCell(XWPFTableCell cell, String text, int tcw) { - styleCell(cell, text, FONT_BODY, SZ_HEAD_FOURTH, true, false, COLOR_WHITE, true, - ParagraphAlignment.CENTER, 0, 0, tcw, null, 0, CELL_MAR, 0, CELL_MAR); + // --------------------------------------------------------------------------- + // 单元格(表头 / 栏头 / 数据 / 图片) + // --------------------------------------------------------------------------- + + /** + * 表头 / 栏头单元格:深蓝底 + 白字 + 加粗 + 居中,文字按列宽自动均衡换行 + * (四号 14pt;换行见 {@link #wrapLines})。 + *

    + * 除列标题行外,巡检详情表内「标注取值用的栏头」也走这里(巡检点位名称 / 巡检时间 / + * 区域名称 / 基准图 / 缺陷图 等),这样标题与取值样式明显区分。 + * + * @param colWidth 该单元格所占列宽合计(twips,跨列时为各列之和) + */ + private void headerCell(XWPFTableCell cell, String text, int colWidth, Integer gridSpan) { + styleCell(cell, wrapLines(text, contentWidth(colWidth), SZ_HEAD_FOURTH, true), + FONT_TABLE_HEAD, SZ_HEAD_FOURTH, true, false, COLOR_WHITE, true, + ParagraphAlignment.CENTER, colWidth, gridSpan, LAYOUT_HEAD); } - /** 正文单元格:仿宋_GB2312 小四 12pt 居中、单倍行距(表格文字居中,与模板一致;center 参数保留兼容调用方) */ - private void bodyCell(XWPFTableCell cell, String text, int tcw, boolean center, - int before, int after) { - styleCell(cell, text, FONT_BODY, SZ_BODY, false, false, null, false, - ParagraphAlignment.CENTER, - before, after, tcw, null, 0, CELL_MAR, 0, CELL_MAR); + /** + * 数据单元格:仿宋_GB2312 小四 12pt、居中、单倍行距(表格文字统一居中,与模板一致) + */ + private void bodyCell(XWPFTableCell cell, String text, int colWidth, Integer gridSpan) { + styleCell(cell, wrapLines(text, contentWidth(colWidth), SZ_BODY, false), + FONT_BODY, SZ_BODY, false, false, null, false, + ParagraphAlignment.CENTER, colWidth, gridSpan, LAYOUT_BODY); } - private void bodyCell(XWPFTableCell cell, String text, int tcw, boolean center) { - bodyCell(cell, text, tcw, center, 0, 0); + private void headerCell(XWPFTableCell cell, String text, int colWidth) { + headerCell(cell, text, colWidth, null); } - private void bodyCell(XWPFTableCell cell, String text, int tcw) { - bodyCell(cell, text, tcw, false, 0, 0); + private void bodyCell(XWPFTableCell cell, String text, int colWidth) { + bodyCell(cell, text, colWidth, null); } - /** 图片单元格:细边框 + 垂直居中 + 图片居中(保持模板尺寸);无图时输出横杠占位 */ - private void imageCell(XWPFTableCell cell, Path imagePath, long w, long h, int tcw) { - styleCell(cell, "", FONT_BODY, SZ_BODY, false, false, null, false, - ParagraphAlignment.CENTER, 120, 120, tcw, null, 0, CELL_MAR, 0, CELL_MAR); + /** 图片单元格:细边框 + 垂直居中 + 图片居中;无图时输出横杠占位 */ + private void imageCell(XWPFTableCell cell, Path imagePath, long w, long h, int colWidth) { + styleCell(cell, singleLine(""), FONT_BODY, SZ_BODY, false, false, null, false, + ParagraphAlignment.CENTER, colWidth, null, LAYOUT_BODY); XWPFParagraph p = cell.getParagraphs().get(0); if (imagePath == null) { // 无图片时以横杠占位,避免空白单元格 XWPFRun dashRun = p.createRun(); - setRunFont(dashRun, "--", FONT_BODY, SZ_BODY, false, false, null, false); + setRunFont(dashRun, "--", FONT_BODY, SZ_BODY, false, false, null); return; } XWPFRun run = p.createRun(); @@ -1084,21 +1314,22 @@ public class DonghuanReportExportController extends BaseController { } /** - * 通用单元格样式:B4C6E7 细边框(sz=0)、垂直居中、可选深蓝底、 - * 列宽、gridSpan、单元格边距 + * 通用单元格样式:B4C6E7 细边框(sz=0)、垂直居中、可选深蓝底、列宽、gridSpan, + * 内边距与段落行高取自 {@link CellLayout}(表头 / 正文 / 封面签署表,均按模板实测值)。 + *

    + * 文字按传入的行列表逐行输出为「一个段落一行」(与模板表头一致):Word 只在段落内自动换行, + * 显式分行可以精确控制表头等短文本的断行位置,避免出现「一个汉字占一行」等不美观的换行。 + * 段落统一居中、段前段后为 0、首行/左右缩进为 0(模板单元格段落同样清零缩进)。 */ - private void styleCell(XWPFTableCell cell, String text, String font, int halfPoints, + private void styleCell(XWPFTableCell cell, List lines, String font, int halfPoints, boolean bold, boolean italic, String color, boolean shade, - ParagraphAlignment align, int before, int after, - Integer tcw, Integer gridSpan, - int marTop, int marLeft, int marBottom, int marRight) { + ParagraphAlignment align, int colWidth, Integer gridSpan, + CellLayout layout) { CTTcPr tcPr = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); - if (tcw != null) { - CTTblWidth w = tcPr.isSetTcW() ? tcPr.getTcW() : tcPr.addNewTcW(); - w.setW(BigInteger.valueOf(tcw)); - w.setType(STTblWidth.DXA); - } + CTTblWidth w = tcPr.isSetTcW() ? tcPr.getTcW() : tcPr.addNewTcW(); + w.setW(BigInteger.valueOf(colWidth)); + w.setType(STTblWidth.DXA); if (gridSpan != null && gridSpan > 1) { CTDecimalNumber gs = tcPr.isSetGridSpan() ? tcPr.getGridSpan() : tcPr.addNewGridSpan(); gs.setVal(BigInteger.valueOf(gridSpan)); @@ -1112,31 +1343,287 @@ public class DonghuanReportExportController extends BaseController { shd.setFill(COLOR_DARK); } CTTcMar tcMar = tcPr.isSetTcMar() ? tcPr.getTcMar() : tcPr.addNewTcMar(); - setCellMar(tcMar, marTop, marLeft, marBottom, marRight); + setCellMar(tcMar, layout.marTop, layout.marLeft, layout.marBottom, layout.marRight); CTVerticalJc vAlign = tcPr.isSetVAlign() ? tcPr.getVAlign() : tcPr.addNewVAlign(); vAlign.setVal(STVerticalJc.CENTER); - // 段落:清空默认段落,写入第一个段落 + // 段落:保留第一段(复用 POI 已登记的段落对象),删除多余段落后按行重建 List ctPList = cell.getCTTc().getPList(); for (int i = ctPList.size() - 1; i > 0; i--) { cell.getCTTc().removeP(i); } - XWPFParagraph p = cell.getParagraphs().get(0); - for (int i = p.getRuns().size() - 1; i >= 0; i--) { - p.removeRun(i); - } - p.setAlignment(align); - p.setSpacingBefore(before); - p.setSpacingAfter(after); - // 表格内段落与模板一致:单倍行距(w:line=240 auto),不受 Normal 样式 1.15 倍影响 - CTPPr cellPPr = p.getCTP().isSetPPr() ? p.getCTP().getPPr() : p.getCTP().addNewPPr(); - CTSpacing cellSpacing = cellPPr.isSetSpacing() ? cellPPr.getSpacing() : cellPPr.addNewSpacing(); - cellSpacing.setLine(BigInteger.valueOf(240)); - cellSpacing.setLineRule(STLineSpacingRule.AUTO); - if (text != null && !text.isEmpty()) { - XWPFRun run = p.createRun(); - setRunFont(run, text, font, halfPoints, bold, italic, color, false); + int lineCount = Math.max(1, lines == null ? 1 : lines.size()); + for (int i = 0; i < lineCount; i++) { + XWPFParagraph p = (i == 0) ? cell.getParagraphs().get(0) : cell.addParagraph(); + for (int r = p.getRuns().size() - 1; r >= 0; r--) { + p.removeRun(r); + } + p.setAlignment(align); + p.setSpacingBefore(0); + // 多行单元格(表头折行)中,行与行之间留 lineGapTw,使行首距与模板一致,最后一行不留 + p.setSpacingAfter(i < lineCount - 1 ? layout.lineGapTw : 0); + // 缩进清零:与模板单元格段落一致(模板显式写 left/right/firstLine=0) + p.setIndentationLeft(0); + p.setIndentationRight(0); + p.setIndentationFirstLine(0); + // 段落行高按 CellLayout(表头 362 贴合文字 / 正文 312 / 封面 560 固定值),与模板实测一致 + CTPPr cellPPr = p.getCTP().isSetPPr() ? p.getCTP().getPPr() : p.getCTP().addNewPPr(); + CTSpacing cellSpacing = cellPPr.isSetSpacing() ? cellPPr.getSpacing() : cellPPr.addNewSpacing(); + cellSpacing.setLine(BigInteger.valueOf(layout.lineTw)); + cellSpacing.setLineRule(layout.lineRule); + String text = (lines == null || i >= lines.size()) ? "" : lines.get(i); + if (text != null && !text.isEmpty()) { + XWPFRun run = p.createRun(); + setRunFont(run, text, font, halfPoints, bold, italic, color); + } + } + } + + // --------------------------------------------------------------------------- + // 换行计算(表头均衡换行 / 正文避免孤字) + // --------------------------------------------------------------------------- + + /** 单行文本包装(不参与自动换行计算) */ + private static List singleLine(String text) { + return Collections.singletonList(text == null ? "" : text); + } + + /** 单元格可用文字宽度(twips)= 列宽 - 左右内边距 */ + private static int contentWidth(int colWidth) { + return Math.max(1, colWidth - 2 * CELL_MAR); + } + + /** 列宽合计(含首尾) */ + private static int sum(int[] widths, int from, int to) { + int total = 0; + for (int i = from; i <= to; i++) { + total += widths[i]; + } + return total; + } + + /** + * 计算单元格文字的显式换行行列表。 + *

    + * 规则: + *

      + *
    1. 按列宽贪心换行(全角字宽 = 字号,半角按一半),遵守避头尾与「不拆半角字串」 + * (如 35kV、1#1、时间戳不会被拆开);找不到合法断点时交回 Word 自动换行;
    2. + *
    3. {@code balanced=true}(表头):两行时按语义拆分(「(个)」等括号单元整体留到末行、 + * 以「名称/结果/状态…」等类别词结尾时按 4+2 或 2+2 拆);
    4. + *
    5. 末行只剩 1 个字(孤字)时把断点前移一位,避免出现「一整行只有一个字」的换行。
    6. + *
    + * + * @return 行文本列表;只有一行时返回 {@code ["原文"]},交由 Word 自动换行 + */ + private static List wrapLines(String text, int availTw, int halfPoints, boolean balanced) { + if (text == null || text.isEmpty()) { + return singleLine(text); + } + int em = halfPoints * 10; // 一个全角字的宽度(twips) + List natural = greedyBreaks(text, availTw, em); + if (natural.isEmpty()) { + return singleLine(text); // 一行放得下,或没有合法断点 + } + List breaks = natural; + int pretty = -1; + if (balanced && natural.size() == 1 && isCjkText(text)) { + // 表头两行且为纯中文:按语义(括号单元 / 类别词)均衡拆分 + pretty = prettySplit(text, availTw, em); + } + if (pretty > 0 && pretty < text.length() && isSafeBreak(text, pretty)) { + breaks = Collections.singletonList(pretty); + } else { + int lastBreak = natural.get(natural.size() - 1); + if (text.length() - lastBreak == 1) { + // 末行只剩 1 个字(孤字)→ 断点前移一位,且保证前一行仍有至少 2 个字 + int prevBreak = natural.size() >= 2 ? natural.get(natural.size() - 2) : 0; + int candidate = lastBreak - 1; + if (candidate - prevBreak >= 2 && isSafeBreak(text, candidate)) { + breaks = new ArrayList<>(natural); + breaks.set(breaks.size() - 1, candidate); + } + } + } + List lines = new ArrayList<>(breaks.size() + 1); + int start = 0; + for (int brk : breaks) { + lines.add(text.substring(start, brk)); + start = brk; + } + lines.add(text.substring(start)); + return lines; + } + + /** + * 贪心换行:返回每个新行的起始下标(不含第一行的 0); + * 无合法断点(例如整段是半角长串)或一行放得下时返回空列表,表示交给 Word 自动换行。 + */ + private static List greedyBreaks(String text, int availTw, int em) { + List breaks = new ArrayList<>(); + int len = text.length(); + int start = 0; + while (start < len) { + int width = 0; + int i = start; + int fitted = start; // 本行按宽度放得下的截止下标 + while (i < len) { + int cw = charWidth(text.charAt(i), em); + if (width + cw > availTw && i > start) { + break; + } + width += cw; + i++; + fitted = i; + } + if (i >= len) { + break; // 剩余内容一行放得下 + } + int brk = fitted; + // 避头尾:行首不能是收尾标点 → 标点留在上一行(与 Word 的避头尾一致,允许该行略微超宽) + while (brk < len && NO_LINE_START.indexOf(text.charAt(brk)) >= 0) { + brk++; + } + if (brk >= len) { + break; // 剩余内容(含末尾标点)都留在本行 + } + // 避头尾:开括号不留行尾 → 移到下一行;不合法(如拆开半角字串)则继续向前找 + while (brk > start && !isSafeBreak(text, brk)) { + brk--; + } + if (brk <= start) { + return Collections.emptyList(); // 本行内没有合法断点:整体交给 Word 自动换行 + } + breaks.add(brk); + start = brk; + } + return breaks; + } + + /** + * 断点是否合法:断点两侧的字符合并成一行不会破坏排版 —— + * 行首不能是收尾标点、行尾不能是开括号、不能把半角字串(英文/数字)拆开。 + */ + private static boolean isSafeBreak(String text, int pos) { + if (pos <= 0 || pos >= text.length()) { + return false; + } + char prev = text.charAt(pos - 1); + char next = text.charAt(pos); + if (NO_LINE_START.indexOf(next) >= 0 || NO_LINE_END.indexOf(prev) >= 0) { + return false; + } + return isFullWidth(prev) || isFullWidth(next); + } + + /** + * 两行表头的优雅断点:优先按语义拆分,否则按字数均衡拆分。 + *
      + *
    • 「巡检点位数量(个)」→「巡检点位数量」/「(个)」;首行放不下时退为 + * 「巡检点位」/「数量(个)」(括号单元连同其前的类别词一起后移);
    • + *
    • 「巡检点位名称」→「巡检点位」/「名称」;「识别类型」→「识别」/「类型」:类别词整体留到末行;
    • + *
    • 其余按字数取中间位置(前少后多,视觉更稳)。
    • + *
    + * + * @return 断点下标;返回 -1 表示没有既符合语义又放得下的拆法(调用方退回按宽度贪心换行) + */ + private static int prettySplit(String text, int availTw, int em) { + int len = text.length(); + List candidates = new ArrayList<>(); + int bracket = trailingBracketStart(text); + if (bracket >= 2) { + candidates.add(bracket); // 「巡检点位数量」/「(个)」 + for (String tail : TAIL_WORDS) { + int p = bracket - tail.length(); // 「巡检点位」/「数量(个)」 + if (p >= 2 && text.startsWith(tail, p)) { + candidates.add(p); + } + } + } + if (len >= 4) { + for (String tail : TAIL_WORDS) { // 「区域」/「名称」 + if (text.endsWith(tail) && len - tail.length() >= 2) { + candidates.add(len - tail.length()); + } + } + } + candidates.add(balancedSplit(text, em)); // 兜底:按宽度均衡拆分 + for (int candidate : candidates) { + // 断点既要语义合理,也要保证两行都放得下,否则 Word 还会再折一次 + if (candidate > 0 && candidate < len && isSafeBreak(text, candidate) + && textWidth(text.substring(0, candidate), em) <= availTw + && textWidth(text.substring(candidate), em) <= availTw) { + return candidate; + } + } + return -1; + } + + /** 按宽度取中间位置(中间偏后断行,避免首行过短) */ + private static int balancedSplit(String text, int em) { + int len = text.length(); + int half = textWidth(text, em) / 2; + int width = 0; + for (int i = 0; i < len; i++) { + width += charWidth(text.charAt(i), em); + if (width >= half) { + return Math.min(len - 1, i + 1); + } + } + return Math.min(len - 1, (len + 1) / 2); + } + + /** 末尾括号单元(「(个)」「(项)」)的起始下标,没有则返回 -1 */ + private static int trailingBracketStart(String text) { + int len = text.length(); + if (len < 3) { + return -1; + } + char last = text.charAt(len - 1); + if (last != ')' && last != ')') { + return -1; + } + for (int i = len - 2; i >= 0; i--) { + char c = text.charAt(i); + if (c == '(' || c == '(') { + return (len - i <= 4) ? i : -1; // 括号单元不超过 4 个字符 + } + } + return -1; + } + + /** 文本宽度(twips):全角按 1 个字宽、半角按 0.5 个字宽估算 */ + private static int textWidth(String text, int em) { + int width = 0; + for (int i = 0; i < text.length(); i++) { + width += charWidth(text.charAt(i), em); + } + return width; + } + + private static int charWidth(char c, int em) { + return isFullWidth(c) ? em : em / 2; + } + + /** 全角字符(汉字/全角标点/假名/韩文等),半角(英文、数字、半角符号)按半宽计 */ + private static boolean isFullWidth(char c) { + return (c >= 0x1100 && c <= 0x115F) + || (c >= 0x2E80 && c <= 0xA4CF) + || (c >= 0xAC00 && c <= 0xD7A3) + || (c >= 0xF900 && c <= 0xFAFF) + || (c >= 0xFE30 && c <= 0xFE6F) + || (c >= 0xFF00 && c <= 0xFF60) + || (c >= 0xFFE0 && c <= 0xFFE6); + } + + /** 纯中文文本(不含半角字符):只有这类文本才插入显式换行,避免拆开英文单词/数字 */ + private static boolean isCjkText(String text) { + for (int i = 0; i < text.length(); i++) { + if (!isFullWidth(text.charAt(i))) { + return false; + } } + return true; } // ============================================================================ diff --git a/inspect-metadata/src/main/resources/mapper/eqpbook/BasedataEqpBookChannelMapper.xml b/inspect-metadata/src/main/resources/mapper/eqpbook/BasedataEqpBookChannelMapper.xml index e98c59b..765ffd6 100644 --- a/inspect-metadata/src/main/resources/mapper/eqpbook/BasedataEqpBookChannelMapper.xml +++ b/inspect-metadata/src/main/resources/mapper/eqpbook/BasedataEqpBookChannelMapper.xml @@ -15,6 +15,7 @@ + @@ -66,6 +67,7 @@ be.area_name, be.station_name, be.station_code, + be.patrol_device_name as eqpBookName, bec.channel_id, bec.channel_code, bec.up_system_device_code,