diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/AbstractDeviceAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/AbstractDeviceAnalyzer.java
index 6a75b38e..7ef51950 100644
--- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/AbstractDeviceAnalyzer.java
+++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/AbstractDeviceAnalyzer.java
@@ -438,7 +438,8 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
}
MetricPointData point = buildMetricPoint(stats, result.getWarnings().get(identity), context,
- dataByIdentity == null ? null : dataByIdentity.get(identity), durationSeconds);
+ dataByIdentity == null ? null : dataByIdentity.get(identity), durationSeconds,
+ identity.equals(primary));
// 主监测指标标记(温度等辅助量为影响量,primary=false)
point.setPrimary(identity.equals(primary));
if (identity.equals(primary)) {
@@ -477,12 +478,26 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
/**
* 构建单个测点数据(使用 ValueFormatter 格式化)
*
+ *
兼容旧签名:默认按主指标口径解析单位。
+ *
* @param data 该物模型的时序数据(可能为相对换算后的序列),用于多口径速率计算
* @param durationSeconds 统计窗口时长(秒)
*/
protected MetricPointData buildMetricPoint(Statistics stats, WarningAnalysis warning, ReportContext context,
List data, long durationSeconds) {
- String resolvedUnit = valueFormatter.resolveUnit(stats.getUnit(), context.getCategory());
+ return buildMetricPoint(stats, warning, context, data, durationSeconds, true);
+ }
+
+ /**
+ * 构建单个测点数据(使用 ValueFormatter 格式化)
+ *
+ * @param data 该物模型的时序数据(可能为相对换算后的序列),用于多口径速率计算
+ * @param durationSeconds 统计窗口时长(秒)
+ * @param primaryMetric 是否为主监测指标(决定单位缺失时是否按监测类别兜底)
+ */
+ protected MetricPointData buildMetricPoint(Statistics stats, WarningAnalysis warning, ReportContext context,
+ List data, long durationSeconds, boolean primaryMetric) {
+ String resolvedUnit = resolveMetricUnit(stats, context, primaryMetric);
String rateUnit = valueFormatter.rateUnit(resolvedUnit);
// 主口径:回归斜率;参考口径:本期平均速率、最大分段速率
@@ -512,6 +527,46 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
.build();
}
+ /**
+ * 解析物模型展示单位
+ *
+ * {@link #backfillModelInfo} 已把空单位按报告主类别兜底写入了 {@code Statistics.unit},
+ * 因此这里必须回到物模型原始配置判断,否则水温、设备电压、角度等辅助量会被错标成主类别单位
+ * (如静力水准的 mm)。
+ *
+ *
+ * - 物模型配置了单位:直接使用;
+ * - 未配置且为主指标:按监测类别兜底(静力水准 → mm);
+ * - 未配置且为辅助量:留空,宁可不显示也不能显示错误单位。
+ *
+ */
+ private String resolveMetricUnit(Statistics stats, ReportContext context, boolean primaryMetric) {
+ ThingsModel model = findModelByIdentifier(context, stats.getIdentifier());
+ String modelUnit = model == null ? null : model.getUnit();
+ if (modelUnit != null && !modelUnit.trim().isEmpty()) {
+ return modelUnit.trim();
+ }
+ if (!primaryMetric) {
+ return "";
+ }
+ return valueFormatter.resolveUnit(null, context.getCategory());
+ }
+
+ /**
+ * 按物模型标识符查找物模型定义
+ */
+ private ThingsModel findModelByIdentifier(ReportContext context, String identifier) {
+ if (context.getThingsModels() == null || identifier == null) {
+ return null;
+ }
+ for (ThingsModel model : context.getThingsModels()) {
+ if (identifier.equals(model.getIdentifier())) {
+ return model;
+ }
+ }
+ return null;
+ }
+
/**
* 生成结论列表
*/
diff --git a/fastbee-report/src/main/java/com/fastbee/report/chart/ChartGenerator.java b/fastbee-report/src/main/java/com/fastbee/report/chart/ChartGenerator.java
index 615844a0..e55ea432 100644
--- a/fastbee-report/src/main/java/com/fastbee/report/chart/ChartGenerator.java
+++ b/fastbee-report/src/main/java/com/fastbee/report/chart/ChartGenerator.java
@@ -1,6 +1,7 @@
package com.fastbee.report.chart;
import com.fastbee.report.data.MetricData;
+import lombok.extern.log4j.Log4j;
import org.jfree.chart.ChartFactory;
import org.jfree.chart.ChartUtils;
import org.jfree.chart.JFreeChart;
@@ -14,8 +15,12 @@ import org.springframework.stereotype.Component;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
+import java.awt.GraphicsEnvironment;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@@ -23,9 +28,17 @@ import java.util.Map;
/**
* Java 图表生成器(JFreeChart → PNG,禁止前端生成图表回传后端)
*
+ * 中文字体策略:
+ *
+ * - 优先加载 classpath 内置 {@code fonts/SimHei.ttf}(跨平台一致,避免 Linux headless 无中文字形导致乱码)
+ * - 失败则按平台探测系统字体(macOS PingFang / Linux WenQuanYi·Noto / Windows SimSun)
+ * - 最后回退 SansSerif(仍乱码但避免空指针)
+ *
+ *
* @author FastBee
* @date 2026-08-16
*/
+@Log4j
@Component
public class ChartGenerator {
@@ -34,8 +47,22 @@ public class ChartGenerator {
System.setProperty("java.awt.headless", "true");
}
- private static final Font CN_FONT = new Font("SansSerif", Font.PLAIN, 13);
- private static final Font CN_TITLE_FONT = new Font("SansSerif", Font.BOLD, 16);
+ /** classpath 内置中文字体路径 */
+ private static final String BUNDLED_FONT_PATH = "/fonts/SimHei.ttf";
+
+ /** 系统字体探测候选(按平台/通用度排序) */
+ private static final String[] SYSTEM_FONT_CANDIDATES = {
+ "PingFang SC", "STHeiti", "Heiti SC", // macOS
+ "WenQuanYi Micro Hei", "WenQuanYi Zen Hei", // Linux (fontconfig)
+ "Noto Sans CJK SC", "Noto Sans SC",
+ "Source Han Sans SC", "Source Han Sans CN",
+ "Microsoft YaHei", "SimSun", "SimHei" // Windows
+ };
+
+ private static final Font BASE_FONT = loadCnFont();
+ private static final Font CN_FONT = BASE_FONT.deriveFont(Font.PLAIN, 13f);
+ private static final Font CN_TITLE_FONT = BASE_FONT.deriveFont(Font.BOLD, 16f);
+ private static final Font CN_TICK_FONT = BASE_FONT.deriveFont(Font.PLAIN, 11f);
/**
* 生成时序曲线图 PNG
@@ -65,9 +92,9 @@ public class ChartGenerator {
((XYLineAndShapeRenderer) plot.getRenderer()).setDefaultStroke(new BasicStroke(1.5f));
}
plot.getDomainAxis().setLabelFont(CN_FONT);
- plot.getDomainAxis().setTickLabelFont(new Font("SansSerif", Font.PLAIN, 11));
+ plot.getDomainAxis().setTickLabelFont(CN_TICK_FONT);
plot.getRangeAxis().setLabelFont(CN_FONT);
- plot.getRangeAxis().setTickLabelFont(new Font("SansSerif", Font.PLAIN, 11));
+ plot.getRangeAxis().setTickLabelFont(CN_TICK_FONT);
chart.getLegend().setItemFont(CN_FONT);
ByteArrayOutputStream out = new ByteArrayOutputStream();
@@ -112,9 +139,9 @@ public class ChartGenerator {
plot.setDomainGridlinePaint(Color.LIGHT_GRAY);
plot.setRangeGridlinePaint(Color.LIGHT_GRAY);
plot.getDomainAxis().setLabelFont(CN_FONT);
- plot.getDomainAxis().setTickLabelFont(new Font("SansSerif", Font.PLAIN, 11));
+ plot.getDomainAxis().setTickLabelFont(CN_TICK_FONT);
plot.getRangeAxis().setLabelFont(CN_FONT);
- plot.getRangeAxis().setTickLabelFont(new Font("SansSerif", Font.PLAIN, 11));
+ plot.getRangeAxis().setTickLabelFont(CN_TICK_FONT);
chart.getLegend().setItemFont(CN_FONT);
ByteArrayOutputStream out = new ByteArrayOutputStream();
@@ -132,4 +159,67 @@ public class ChartGenerator {
public byte[] blankChart(String title) {
return multiSeriesChart(title, null, new java.util.LinkedHashMap<>());
}
+
+ /**
+ * 加载中文字体:classpath 内置优先 → 系统字体探测 → SansSerif 兜底
+ *
+ * JFreeChart 在 Linux headless 环境下默认使用 SansSerif 逻辑字体,
+ * 若系统未安装中文字体映射,标题/图例/坐标轴中文会渲染成方框(豆腐块)。
+ * 故优先把项目内置的 {@code fonts/SimHei.ttf} 注册到 GraphicsEnvironment,
+ * 保证图表在任何部署环境下都能正常显示中文。
+ */
+ private static Font loadCnFont() {
+ // 1. classpath 内置字体(跨环境一致)
+ Font bundled = loadBundledFont();
+ if (bundled != null) {
+ return bundled;
+ }
+ // 2. 系统字体探测
+ for (String name : SYSTEM_FONT_CANDIDATES) {
+ Font f = new Font(name, Font.PLAIN, 13);
+ if (f.canDisplay('中') && f.canDisplay('静')) {
+ return f;
+ }
+ }
+ // 3. 回退(仍可能乱码,但避免空指针)
+ return new Font("SansSerif", Font.PLAIN, 13);
+ }
+
+ /**
+ * 从 classpath 加载内置 TTF 字体并注册到本地图形环境
+ *
+ * {@code Font.createFont(int, File)} 不接受 InputStream,需先落临时文件。
+ */
+ private static Font loadBundledFont() {
+ java.io.File tmp = null;
+ try (InputStream in = ChartGenerator.class.getResourceAsStream(BUNDLED_FONT_PATH)) {
+ if (in == null) {
+ return null;
+ }
+ tmp = java.io.File.createTempFile("report-cn-font-", ".ttf");
+ tmp.deleteOnExit();
+ try (OutputStream out = Files.newOutputStream(tmp.toPath())) {
+ byte[] buf = new byte[8192];
+ int n;
+ while ((n = in.read(buf)) > 0) {
+ out.write(buf, 0, n);
+ }
+ }
+ Font f = Font.createFont(Font.TRUETYPE_FONT, tmp);
+ try {
+ GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(f);
+ } catch (Exception ignored) {
+ // 注册失败不影响 createFont 已返回的字体使用
+ }
+ return f;
+ } catch (Exception e) {
+ // 加载失败回退系统探测,不影响图表生成主流程
+ return null;
+ } finally {
+ if (tmp != null && tmp.exists()) {
+ // JVM 持有临时文件句柄,createFont 后可删除;保留 deleteOnExit 兜底
+ log.error("JVM 持有临时文件句柄,createFont 后可删除;保留 deleteOnExit 兜底");
+ }
+ }
+ }
}
diff --git a/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java b/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java
index d3eb17d9..0c4768f0 100644
--- a/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java
+++ b/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java
@@ -11,7 +11,9 @@ import com.deepoove.poi.data.Rows;
import com.deepoove.poi.data.style.PictureStyle;
import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy;
import com.deepoove.poi.policy.TableRenderPolicy;
+import com.fastbee.common.enums.MonitorCategory;
import com.fastbee.common.utils.StringUtils;
+import com.fastbee.report.analysis.ValueFormatter;
import com.fastbee.report.exception.ReportException;
import com.fastbee.report.model.AnalysisResult;
import com.fastbee.report.model.Conclusion;
@@ -25,6 +27,13 @@ import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblGrid;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblLayoutType;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblWidth;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblLayoutType;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblWidth;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
@@ -32,6 +41,7 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
+import java.math.BigInteger;
import java.math.RoundingMode;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
@@ -40,7 +50,6 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
-import java.util.Map;
/**
* Word 渲染器(poi-tl 模板渲染 + JFreeChart PNG 嵌入,禁止查库)
@@ -54,6 +63,9 @@ public class WordRenderer implements ReportRenderer {
@Resource
private WordTemplateResolver templateResolver;
+ /** 类别精度解析(静力水准/深部位移 2 位、温度 1 位…),表格数值与正文口径保持一致 */
+ private static final ValueFormatter VALUE_FORMATTER = new ValueFormatter();
+
@Override
public byte[] render(ReportModel model) {
byte[] template = templateResolver.resolve(model.getTemplateCode());
@@ -68,8 +80,8 @@ public class WordRenderer implements ReportRenderer {
try {
ByteArrayOutputStream out = new ByteArrayOutputStream();
tpl.write(out);
- // 后处理:清除循环表格残留的空行,避免排版出现空白行
- return cleanupEmptyTableRows(out.toByteArray());
+ // 后处理:清除循环表格残留空行 + 测点表列宽分配(避免均分导致内容竖排)
+ return postProcessDocument(out.toByteArray());
} catch (IOException e) {
throw new ReportException("Word 渲染失败: " + e.getMessage(), e);
} finally {
@@ -81,18 +93,26 @@ public class WordRenderer implements ReportRenderer {
}
}
+ /** A4 纵向页面正文可用宽度(twip),与模板中固定表格宽度保持一致 */
+ private static final int A4_CONTENT_WIDTH_TWIP = 9026;
+
/**
- * 清除表格中的空白行(poi-tl 循环表格的标记行渲染后会残留为空行)
+ * 渲染后处理:清空行 + 测点表列宽分配
*/
- private byte[] cleanupEmptyTableRows(byte[] docx) {
+ private byte[] postProcessDocument(byte[] docx) {
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
for (XWPFTable table : document.getTables()) {
+ // 1. 清除 poi-tl 循环表格的标记行渲染后残留的空行
List rows = table.getRows();
for (int i = rows.size() - 1; i >= 0; i--) {
if (isRowBlank(rows.get(i))) {
table.removeRow(i);
}
}
+ // 2. 测点表列数多,按内容权重分配列宽,避免 poi-tl 默认均分导致每列过窄、表头竖排
+ if (isMetricPointTable(table)) {
+ applyColumnWidths(table);
+ }
}
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
document.write(buffer);
@@ -103,6 +123,102 @@ public class WordRenderer implements ReportRenderer {
}
}
+ /**
+ * 识别测点表:表头首列为「测点编号」(带断面时为「断面」)
+ */
+ private boolean isMetricPointTable(XWPFTable table) {
+ List rows = table.getRows();
+ if (rows.isEmpty() || rows.get(0).getTableCells().isEmpty()) {
+ return false;
+ }
+ String firstHeader = rows.get(0).getCell(0).getText();
+ return firstHeader != null && (firstHeader.contains("测点编号") || firstHeader.contains("断面"));
+ }
+
+ /**
+ * 按内容权重给测点表分配列宽(固定布局)
+ *
+ * poi-tl 的 {@code Tables.ofA4Width()} 会把 A4 宽度均分给所有列:20 列时每列仅约 414 twip
+ * (≈7.3mm),9pt 汉字表头被迫逐字竖排。此处按表头语义给列赋权重,宽度按权重比例分配,
+ * 并设置 {@code tblLayout=fixed} 让 Word 严格按指定宽度渲染。
+ */
+ private void applyColumnWidths(XWPFTable table) {
+ List rows = table.getRows();
+ int columnCount = rows.get(0).getTableCells().size();
+ if (columnCount == 0) {
+ return;
+ }
+
+ // 表格整体:固定布局 + A4 可用宽度
+ CTTblPr tblPr = table.getCTTbl().getTblPr();
+ if (tblPr == null) {
+ tblPr = table.getCTTbl().addNewTblPr();
+ }
+ CTTblLayoutType layout = tblPr.isSetTblLayout() ? tblPr.getTblLayout() : tblPr.addNewTblLayout();
+ layout.setType(STTblLayoutType.FIXED);
+ CTTblWidth tableWidth = tblPr.isSetTblW() ? tblPr.getTblW() : tblPr.addNewTblW();
+ tableWidth.setType(STTblWidth.DXA);
+ tableWidth.setW(BigInteger.valueOf(A4_CONTENT_WIDTH_TWIP));
+
+ // 计算各列权重
+ int[] weights = new int[columnCount];
+ int totalWeight = 0;
+ for (int i = 0; i < columnCount; i++) {
+ weights[i] = columnWeight(rows.get(0).getCell(i).getText());
+ totalWeight += weights[i];
+ }
+ if (totalWeight <= 0) {
+ return;
+ }
+
+ CTTblGrid grid = table.getCTTbl().getTblGrid();
+ for (int i = 0; i < columnCount; i++) {
+ int width = Math.round((float) A4_CONTENT_WIDTH_TWIP * weights[i] / totalWeight);
+ if (grid != null && i < grid.sizeOfGridColArray()) {
+ grid.getGridColArray(i).setW(BigInteger.valueOf(width));
+ }
+ for (XWPFTableRow row : rows) {
+ if (i >= row.getTableCells().size()) {
+ continue;
+ }
+ CTTcPr tcPr = row.getCell(i).getCTTc().isSetTcPr()
+ ? row.getCell(i).getCTTc().getTcPr()
+ : row.getCell(i).getCTTc().addNewTcPr();
+ CTTblWidth cellWidth = tcPr.isSetTcW() ? tcPr.getTcW() : tcPr.addNewTcW();
+ cellWidth.setType(STTblWidth.DXA);
+ cellWidth.setW(BigInteger.valueOf(width));
+ }
+ }
+ }
+
+ /**
+ * 列宽权重:表头文字越长、内容越宽,权重越高
+ */
+ private int columnWeight(String headerText) {
+ String text = headerText == null ? "" : headerText.trim();
+ if (text.startsWith("断面")) {
+ return 12;
+ }
+ if (text.startsWith("测点编号")) {
+ return 12;
+ }
+ if (text.startsWith("测点名称")) {
+ return 14;
+ }
+ if (text.startsWith("初始观测时间")) {
+ return 16;
+ }
+ if (text.startsWith("监测结论")) {
+ return 10;
+ }
+ // 辅助量合并列:本次值(变化量),内容较长
+ if (text.contains("(变化量)")) {
+ return 13;
+ }
+ // 主指标:初始值 / 本次值 / 变化量 / 变化速率(数值最长,权重最高)
+ return 11;
+ }
+
/**
* 判断表格行是否完全空白
*/
@@ -236,7 +352,7 @@ public class WordRenderer implements ReportRenderer {
}
data.put("metricPoints", metricPointsList);
// 动态列测点表:列由导出设备的物模型(物理量)决定,一行一个测点(iot_device)
- data.put("metricTable", buildMetricTable(analysis));
+ data.put("metricTable", buildMetricTable(analysis, model.getCategory()));
// 图表:兼容 timeSeriesChart(主指标)/ chart2 / chart3(分组指标)/ crackTrend(旧模板)
Map charts = new HashMap<>();
@@ -290,11 +406,14 @@ public class WordRenderer implements ReportRenderer {
*
* - 断面:仅按设备分组(iot_group)导出时存在,否则不生成该列;
* - 主监测指标:初始值 / 本次值 / 变化量 / 变化速率(完整四列,主导报告结论);
- * - 其余物模型(如温度等影响量):本次值 / 变化量,保留物理量完整度;
+ * - 其余物模型(如温度等影响量):本次值(变化量) 合并为一列,保留物理量完整度又不撑爆列宽;
* - 初始观测时间、监测结论:以主指标为准,结论取该测点所有物模型中的最严重等级。
*
+ *
+ * 数值精度与正文一致,按监测类别取 {@link ValueFormatter} 配置(静力水准 2 位),
+ * 避免 6 位小数把列撑宽导致内容折行。
*/
- private TableRenderData buildMetricTable(AnalysisResult analysis) {
+ private TableRenderData buildMetricTable(AnalysisResult analysis, MonitorCategory category) {
List points = analysis.getMetricPoints() == null
? new ArrayList<>() : analysis.getMetricPoints();
if (points.isEmpty()) {
@@ -344,16 +463,17 @@ public class WordRenderer implements ReportRenderer {
header.add(name + "·变化量" + unitSuffix(unit));
header.add(name + "·变化速率" + unitSuffix(sample == null ? "" : nvl(sample.getRateUnit())));
} else {
- header.add(name + "·本次值" + unitSuffix(unit));
- header.add(name + "·变化量" + unitSuffix(unit));
+ // 辅助量(水温/电压/角度等)本次值与变化量合并为一列,避免列数膨胀后每列过窄
+ header.add(name + "·本次值(变化量)" + unitSuffix(unit));
}
}
header.add("初始观测时间");
header.add("监测结论");
Tables.TableBuilder builder = Tables.ofA4Width().center();
+ // 表头字号小于数据行,减少长表头(如"设备电压·本次值(变化量)(V)")的折行行数
builder.addRow(Rows.of(header.toArray(new String[0]))
- .bgColor("DCE6F1").textBold().center().textFontSize(9).create());
+ .bgColor("DCE6F1").textBold().center().textFontSize(8).create());
// 4. 数据行
for (Map.Entry> entry : byPoint.entrySet()) {
@@ -378,22 +498,20 @@ public class WordRenderer implements ReportRenderer {
row.add("-");
row.add("-");
row.add("-");
- } else {
- row.add("-");
}
continue;
}
if (identifier.equals(primaryIdentifier)) {
- row.add(str(point.getInitialValue()));
- row.add(str(point.getCurrentValue()));
- row.add(str(point.getChangeValue()));
- row.add(str(point.getChangeRate()));
+ row.add(strForTable(point.getInitialValue(), category));
+ row.add(strForTable(point.getCurrentValue(), category));
+ row.add(strForTable(point.getChangeValue(), category));
+ row.add(strForTable(point.getChangeRate(), category));
} else {
- row.add(str(point.getCurrentValue()));
- row.add(str(point.getChangeValue()));
+ // 辅助量合并列:本次值(变化量)
+ row.add(mergedValue(point.getCurrentValue(), point.getChangeValue(), category));
}
}
- row.add(fmt(primaryTime(rowPoints, primaryIdentifier)));
+ row.add(fmtShort(primaryTime(rowPoints, primaryIdentifier)));
row.add(worstStatusText(rowPoints, primaryIdentifier));
builder.addRow(Rows.of(row.toArray(new String[0])).center().textFontSize(9).create());
}
@@ -460,6 +578,50 @@ public class WordRenderer implements ReportRenderer {
return date == null ? "-" : new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date);
}
+ /** 时间格式化:yyyy-MM-dd HH:mm,空值返回 "-" */
+ private String fmtShort(Date date) {
+ return date == null ? "-" : new SimpleDateFormat("yyyy-MM-dd HH:mm").format(date);
+ }
+
+ /**
+ * 辅助量合并单元格:{@code 本次值(变化量)},变化量带正负号
+ *
+ * 例:{@code 28.56(+0.25)}、{@code 11.80(0)};本次值缺失时返回 "-"。
+ */
+ private String mergedValue(BigDecimal current, BigDecimal change, MonitorCategory category) {
+ if (current == null) {
+ return "-";
+ }
+ return strForTable(current, category) + "(" + signed(change, category) + ")";
+ }
+
+ /** 变化量带符号展示(正数显式补 +,便于一眼看出升降) */
+ private String signed(BigDecimal value, MonitorCategory category) {
+ if (value == null) {
+ return "-";
+ }
+ if (value.compareTo(BigDecimal.ZERO) == 0) {
+ return "0";
+ }
+ String text = strForTable(value, category);
+ return text.startsWith("-") ? text : "+" + text;
+ }
+
+ /**
+ * 表格数值格式化:按监测类别精度(静力水准/深部位移 2 位、温度 1 位…)并去除尾零
+ *
+ * 与正文({@link ValueFormatter})保持同一口径;无类别配置时保留 6 位。
+ */
+ private String strForTable(BigDecimal value, MonitorCategory category) {
+ if (value == null) {
+ return "-";
+ }
+ ValueFormatter.CategoryFormat format = VALUE_FORMATTER.getCategoryFormat(category);
+ int decimals = format == null ? 6 : format.decimalPlaces;
+ BigDecimal formatted = value.setScale(decimals, RoundingMode.HALF_UP).stripTrailingZeros();
+ return formatted.compareTo(BigDecimal.ZERO) == 0 ? "0" : formatted.toPlainString();
+ }
+
/** 拼接数值与单位(单位为空时只返回数值,避免尾部空格) */
private String withUnit(String value, String unit) {
return (unit == null || unit.trim().isEmpty()) ? value : value + " " + unit.trim();
diff --git a/fastbee-report/src/main/resources/fonts/SimHei.ttf b/fastbee-report/src/main/resources/fonts/SimHei.ttf
new file mode 100644
index 00000000..c5030aea
Binary files /dev/null and b/fastbee-report/src/main/resources/fonts/SimHei.ttf differ
diff --git a/yack-web/src/views/dataCenter/reportHistory.vue b/yack-web/src/views/dataCenter/reportHistory.vue
index ffa7b429..9e155449 100644
--- a/yack-web/src/views/dataCenter/reportHistory.vue
+++ b/yack-web/src/views/dataCenter/reportHistory.vue
@@ -47,7 +47,7 @@
{{ statusText(scope.row.status) }}
-
+
下载
失败原因
重新生成