reportEngine:报告监测值修改,支持多个

This commit is contained in:
zhugongping 2026-09-26 11:38:13 +08:00
parent f446ee15e6
commit 6f20645dd9
37 changed files with 799 additions and 699 deletions

View File

@ -173,6 +173,37 @@ fastbee-report/
- 模板:`DEVICE_CRACK_METER_V2.docx` 等,按 `template_code`(`DEVICE_${CATEGORY}_V2`)从 classpath 的 `templates/` 目录加载(`DefaultWordTemplateResolver`)。模板与渲染逻辑强耦合,**不入库**,随代码版本发布。
- 变量:`{{report.no}}`、`{{report.periodDesc}}`、`{{device.name}}`、`{{device.serialNumber}}`、`{{analysis.statistics.max}}`、`{{analysis.statistics.delta}}`、`{{analysis.trend.description}}`、`{{analysis.warning.status}}`、循环表格 `{{conclusions}}`、图片 `{{charts.crackTrend}}` 等;模板不直接绑定数据库字段。
## 3.8 主监测指标(模板里的"监测指标")
`{{device.metricName}}` 的值来自 `AnalysisResult.primaryIdentifier` → `Statistics.modelName`
(即 `iot_things_model.model_name` 的中文名),由 `PrimaryMetricResolver.resolve` 判定,
优先级从高到低:
1. **用户显式指定** —— `iot_things_threshold.is_primary` 为真的物模型直接当选,
不受辅助量黑名单与关键词限制。级别口径与阈值数值**同一套**(逐标识符 `device > product`):
设备级配了该物模型就以设备行为准(设备行没勾、产品行勾了也不作数),设备没配才继承产品行的勾选。
多个标记项同时有数据时,取物模型列表顺序最靠前的(与关键词命中同口径);
2. 名称/标识符命中 `Analyzer#primaryMetricKeywords()` 的物模型,同类中优先该时段有数据的;
3. 第一个有数据的非辅助指标;
4. 第一个非辅助指标;最后兜底物模型列表首项。
**为什么需要第 1 条**:关键词表硬编码在 Java 里,物模型命名不规范时第 2 条整体落空、静默退化为第 3/4 条
——等于把 `ORDER BY model_order DESC` 的排序结果当主指标。现场实例:轴力计产品因物模型叫"锚力"/"压力"、
关键词只有"轴力/应力",把 GPS"经度"(`model_order` 更靠前)选成了监测指标。
配置入口:`yack-web` 阈值配置页 `views/iot/components/threshold-config.vue` 的「主监测指标」开关,
与阈值同表同行落库,无需额外表。
**读取方式**:不单独查询,直接复用 `ThingsThresholdProvider#find`(`selectByPriority`)逐物模型查回的
阈值行里的 `is_primary` —— 级别口径天然与阈值数值一致,不会出现"标记说它是主指标、阈值数值却取设备行"
的自相矛盾状态。提取逻辑在 `ReportContextBuilder#resolveExplicitPrimary`(包级静态,可单测)。
设备页可见性:本设备未配置该物模型 → 开关旁显示「继承」;本设备已配置但未勾选、而产品级勾了 →
显示「覆盖产品」并提示报告不会用它作主指标(设备优先口径下该勾选确实失效,必须显式告知)。
> 注意此处**不是并集**:同一个物模型两级都配置时,设备行的 `is_primary` 单独决定结果;
> 不同物模型各自独立判断(互不影响)。
---
# 4. 存储方案:MinIO(关键决策)

View File

@ -5,6 +5,7 @@ import com.fastbee.report.core.ReportContext;
import com.fastbee.report.data.MetricData;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@ -13,12 +14,30 @@ import java.util.Map;
*
* <p>判定规则(按物模型列表顺序,保证稳定、不依赖时序数据返回顺序):</p>
* <ol>
* <li>排除辅助指标(角度/温度/电压/电量/信号/湿度等),辅助量不作为主监测指标;</li>
* <li>优先返回名称或标识符命中核心关键词的物模型;</li>
* <li><b>用户显式指定优先</b>:{@code iot_things_threshold.is_primary=1} 的物模型直接当选,
* 不受辅助量黑名单与关键词限制。级别口径与阈值数值同一套(逐标识符 device &gt; product):
* 设备级配了该物模型就以设备行为准,设备没配才继承产品级的勾选;
* 多个标记项同时有数据时,取物模型列表顺序最靠前的(与关键词命中同口径);</li>
* <li>排除辅助指标(角度/温度/电压/电量/信号/湿度/GPS 定位等),辅助量不作为主监测指标;</li>
* <li>优先返回名称或标识符命中核心关键词的物模型;<b>同类命中者中优先取该时段有数据的</b>;</li>
* <li>否则返回第一个有数据的非辅助指标;</li>
* <li>再否则返回第一个非辅助指标;最后兜底第一个物模型。</li>
* </ol>
*
* <p><b>为什么"有数据优先"是必须的</b>:同一产品常存在多个同名物模型(如振弦式轴力计的
* {@code MSJ_KN} / {@code KN} 都叫"压力"),若关键词命中即返回,会选中未上报的那一个,
* 使主指标统计为空。</p>
*
* <p><b>为什么关键词匹配是决定性的</b>:遍历顺序由物模型查询排序决定
* ({@code ThingsModelMapper.xml#selectThingsModelList} 为 {@code ORDER BY model_order DESC}),
* 关键词全部落空时会退化为"按排序取第一个非辅助量"——等于把排序结果当主指标。
* 现场曾有轴力计产品因物模型命名为"压力"、而关键词表只有"轴力/应力",最终把 GPS"经度"
* 选成了监测指标。接入新设备类型时务必核对关键词与现场物模型命名。</p>
*
* <p><b>显式指定与关键词推断的关系</b>:关键词表是硬编码在 Java 里的,物模型命名不规范时
* 静默退化为兜底排序,这让现场无法自救。显式指定把最终决定权交回配置人员,
* 关键词推断降级为「未配置时的零配置默认」,保证存量几百个产品不改配置行为不变。</p>
*
* @author FastBee
* @date 2026-09-10
*/
@ -29,15 +48,19 @@ public class PrimaryMetricResolver {
private static final String[] AUXILIARY_KEYWORDS = {
"角度", "angle", "温度", "temperature", "电压", "voltage",
"电量", "battery", "信号", "signal", "湿度", "humidity",
"电压值", "battery_voltage"
"电压值", "battery_voltage",
// 采集仪自带的 GPS 定位量:描述设备位置,不是被监测的物理量
"经度", "longitude", "纬度", "latitude"
};
/**
* 解析主监测指标标识符
* 解析主监测指标标识符(单值口径,不允许辅助指标作主指标)
*
* @param context 报告上下文(提供物模型列表,顺序稳定)
* <p>保留向后兼容:等价于 {@link #resolveAll} 后取列表第一个。</p>
*
* @param context 报告上下文(提供物模型列表,顺序稳定)
* @param byIdentity 时序数据分组(用于判断是否有数据)
* @param keywords 核心关键词(如"位移"、"缝宽"、"轴力")
* @param keywords 核心关键词(如"位移"、"缝宽"、"轴力")
* @return 主监测指标标识符;无物模型时返回 null
*/
public String resolve(ReportContext context, Map<String, List<MetricData>> byIdentity, String... keywords) {
@ -45,18 +68,42 @@ public class PrimaryMetricResolver {
}
/**
* 解析主监测指标标识符
* 解析主监测指标标识符(单值口径)
*
* @param allowAuxiliary 是否允许辅助指标(角度/温度等)作为主指标。
* 倾角计、温度计等设备的主监测指标本身就是"角度/温度",需传 true。
*/
public String resolve(ReportContext context, Map<String, List<MetricData>> byIdentity,
boolean allowAuxiliary, String... keywords) {
List<String> all = resolveAll(context, byIdentity, allowAuxiliary, keywords);
return all == null || all.isEmpty() ? null : all.get(0);
}
/**
* 解析全部主监测指标标识符(多值口径,保持物模型列表顺序)
*
* <p>与 {@link #resolve} 的判定规则一致,区别在于:</p>
* <ul>
* <li>用户显式勾选({@code is_primary=1})时,<b>收集全部命中的标识符</b>,
* 而不是只取列表最靠前的一个 —— 静力水准仪等设备可同时把「水位」「水温」都设为主指标;</li>
* <li>关键词命中 / 兜底分支仍只返回<b>一个</b>标识符(单元素列表),
* 因为零配置时无法推断多个主指标,保持存量产品行为不变。</li>
* </ul>
*
* @return 主监测指标标识符列表(有序);无物模型时返回空列表
*/
public List<String> resolveAll(ReportContext context, Map<String, List<MetricData>> byIdentity,
boolean allowAuxiliary, String... keywords) {
List<ThingsModel> models = context.getThingsModels();
if (models == null || models.isEmpty()) {
return byIdentity.isEmpty() ? null : byIdentity.keySet().iterator().next();
return byIdentity.isEmpty() ? new ArrayList<>() : new ArrayList<>(byIdentity.keySet());
}
// 显式指定集合(有序):用户意志优先,收集全部命中项
List<String> explicitAll = new ArrayList<>();
// 兜底候选:关键词命中 / 第一个有数据 / 第一个非辅助
String keywordHitWithData = null;
String keywordHit = null;
String firstWithData = null;
String firstAny = null;
@ -68,22 +115,34 @@ public class PrimaryMetricResolver {
String name = model.getModelName() == null ? identity : model.getModelName();
String lower = (name + " " + identity).toLowerCase();
boolean auxiliary = isAuxiliary(lower);
// 倾角计/温度计允许辅助指标作为主指标
boolean eligibleAsPrimary = !auxiliary || allowAuxiliary;
boolean hasData = byIdentity.containsKey(identity);
// 核心关键词命中优先(倾角计/温度计允许辅助指标作为主指标)
if (!auxiliary || allowAuxiliary) {
for (String keyword : keywords) {
if (lower.contains(keyword.toLowerCase())) {
return identity;
}
// 用户显式指定最高优先:不受辅助量黑名单与关键词限制,显式即用户意志
// (用户若明确把"电压"标成主指标,就应当被尊重,而不是被辅助量规则否决)
// 多选场景:收集全部,且按「有数据优先」保证主指标列表稳定、不受数据时序影响
if (context.isPrimaryMetric(identity)) {
if (hasData) {
explicitAll.add(identity);
}
}
// 核心关键词命中优先(兜底路径用)
if (eligibleAsPrimary && matchesKeyword(lower, keywords)) {
if (keywordHit == null) {
keywordHit = identity;
}
if (keywordHitWithData == null && hasData) {
keywordHitWithData = identity;
}
}
// 辅助指标不参与"第一个"兜底
if (auxiliary && !allowAuxiliary) {
if (!eligibleAsPrimary) {
continue;
}
boolean hasData = byIdentity.containsKey(identity);
if (firstAny == null) {
firstAny = identity;
}
@ -92,18 +151,69 @@ public class PrimaryMetricResolver {
}
}
// ① 用户显式指定:全部命中项;若都无数据,退回「全部显式项(不要求有数据)」
if (!explicitAll.isEmpty()) {
return explicitAll;
}
List<String> explicitAny = collectExplicitAny(models, context);
if (!explicitAny.isEmpty()) {
return explicitAny;
}
// ② 关键词命中;同类命中者中优先该时段有数据的同名物模型
if (keywordHitWithData != null) {
return single(keywordHitWithData);
}
if (keywordHit != null) {
return single(keywordHit);
}
if (firstWithData != null) {
return firstWithData;
return single(firstWithData);
}
if (firstAny != null) {
return firstAny;
return single(firstAny);
}
// 全部是辅助指标时,退回第一个物模型
return models.get(0).getIdentifier();
return single(models.get(0).getIdentifier());
}
/** 收集全部显式勾选的主指标(不要求有数据,用于显式项全无数据时的兜底) */
private List<String> collectExplicitAny(List<ThingsModel> models, ReportContext context) {
List<String> result = new ArrayList<>();
for (ThingsModel model : models) {
String identity = model.getIdentifier();
if (identity != null && context.isPrimaryMetric(identity) && !result.contains(identity)) {
result.add(identity);
}
}
return result;
}
private List<String> single(String identity) {
List<String> result = new ArrayList<>(1);
if (identity != null) {
result.add(identity);
}
return result;
}
/**
* 是否为辅助指标(角度/温度/电压/电量/信号/湿度等)
* 名称或标识符是否命中任一核心关键词
*/
private boolean matchesKeyword(String nameAndIdentifier, String[] keywords) {
if (keywords == null || keywords.length == 0) {
return false;
}
for (String keyword : keywords) {
if (keyword != null && !keyword.isEmpty()
&& nameAndIdentifier.contains(keyword.toLowerCase())) {
return true;
}
}
return false;
}
/**
* 是否为辅助指标(角度/温度/电压/电量/信号/湿度/GPS 定位等)
*/
public boolean isAuxiliary(String nameAndIdentifier) {
for (String keyword : AUXILIARY_KEYWORDS) {
@ -113,14 +223,4 @@ public class PrimaryMetricResolver {
}
return false;
}
/**
* 是否需要生成独立图表(辅助指标中仅温度/电压等少量关注项可跳过)
*/
public boolean isChartWorthy(String nameAndIdentifier) {
return !nameAndIdentifier.contains("电量")
&& !nameAndIdentifier.contains("battery")
&& !nameAndIdentifier.contains("信号")
&& !nameAndIdentifier.contains("signal");
}
}

View File

@ -1,6 +1,10 @@
package com.fastbee.report.analysis;
import com.alibaba.fastjson2.JSON;
import com.fastbee.common.enums.MonitorCategory;
import com.fastbee.common.utils.StringUtils;
import com.fastbee.iot.domain.ThingsModel;
import com.fastbee.iot.model.ThingsModelItem.Datatype;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
@ -91,6 +95,38 @@ public class ValueFormatter {
return (fmt != null) ? fmt.defaultUnit : "";
}
/**
* 读取物模型配置的单位
*
* <p><b>不要用 {@code ThingsModel.getUnit()}</b>:单位实际存放在
* {@code iot_things_model.specs} JSON 的 {@code unit} 字段中,而 {@code ThingsModel#unit}
* 在表里没有对应列、查询语句也不映射它,实体 getter 取到的永远是 null
* ——这会让主指标单位被类别默认值顶替(把"经度"标成 kN),辅助量单位则整体丢失。</p>
*
* @param model 物模型
* @return 单位;未配置或 specs 解析失败返回 null
*/
public static String unitOf(ThingsModel model) {
if (model == null) {
return null;
}
if (StringUtils.isNotEmpty(model.getUnit())) {
return model.getUnit().trim();
}
String specs = model.getSpecs();
if (StringUtils.isEmpty(specs)) {
return null;
}
try {
Datatype datatype = JSON.parseObject(specs, Datatype.class);
String unit = datatype == null ? null : datatype.getUnit();
return StringUtils.isEmpty(unit) ? null : unit.trim();
} catch (Exception e) {
// specs 非标准结构时按"未配置单位"处理,不影响报告生成
return null;
}
}
/**
* 带单位的显示值
*

View File

@ -32,8 +32,10 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
@ -281,7 +283,7 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
BigDecimal controlValue = threshold == null ? null : threshold.getControlValue();
Statistics statistics = statisticsCalculator.calculate(data);
backfillModelInfo(statistics, model, context.getCategory());
backfillModelInfo(statistics, model);
TrendAnalysis trend = trendAnalyzer.analyze(data);
// 越界检测按「相对初始值的累计变化量」判定,不能直接拿原始读数比控制值
@ -302,14 +304,19 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
}
// 4. 主指标(关键词匹配 + 辅助量过滤)
String primaryIdentity = primaryMetricResolver.resolve(
// 多主指标:用户可显式勾选多个 is_primary=1 物模型(如水位 + 水温),全部收集;
// primaryIdentifier 保留为「主导主指标」,用于单值章节(概况/结论/安全等级/控制值/主图)。
List<String> primaryIdentifiers = primaryMetricResolver.resolveAll(
context, byIdentity, allowAuxiliaryAsPrimary(), primaryMetricKeywords());
result.setPrimaryIdentifiers(primaryIdentifiers);
String primaryIdentity = primaryIdentifiers == null || primaryIdentifiers.isEmpty()
? null : primaryIdentifiers.get(0);
result.setPrimaryIdentifier(primaryIdentity);
// 5. 组装测点数据(主指标排第一,辅助量作为影响量保留)
result.setMetricPoints(buildMetricPointsFiltered(result, context, dataByIdentity, statisticsDurationSeconds));
// 6. 生成结论
// 6. 生成结论(以主导主指标为准,保证概况/结论/安全等级口径一致)
buildConclusions(result, context, primaryIdentity);
// 7. 领域特定结论
@ -401,15 +408,17 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
}
/**
* 回填物模型信息 + 单位兜底
* 回填物模型信息
*
* <p>单位只取物模型实配值,<b>不在这里做类别兜底</b>:兜底必须知道"该物模型是不是主指标"
* (水温、设备电压、GPS 经度等辅助量不能被标成主类别单位 mm/kN),
* 该判断由 {@link #resolveMetricUnit(Statistics, ReportContext, boolean)} 负责。</p>
*/
protected void backfillModelInfo(Statistics statistics, ThingsModel model, MonitorCategory category) {
protected void backfillModelInfo(Statistics statistics, ThingsModel model) {
if (statistics.getModelName() == null || statistics.getModelName().isEmpty()) {
statistics.setModelName(model.getModelName());
}
// 单位兜底:物模型优先,否则按类别默认
String resolvedUnit = valueFormatter.resolveUnit(model.getUnit(), category);
statistics.setUnit(resolvedUnit);
statistics.setUnit(ValueFormatter.unitOf(model));
statistics.setIdentifier(model.getIdentifier());
}
@ -424,8 +433,9 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
Map<String, List<MetricData>> dataByIdentity,
long durationSeconds) {
List<MetricPointData> all = new ArrayList<>();
String primary = result.getPrimaryIdentifier();
MetricPointData primaryPoint = null;
Set<String> primarySet = primaryIdentifierSet(result);
// 多主指标:每个主指标都排在前,均标记 primary=true(各出完整四列)
List<MetricPointData> primaryPoints = new ArrayList<>();
for (Map.Entry<String, Statistics> entry : result.getStatistics().entrySet()) {
String identity = entry.getKey();
@ -437,32 +447,48 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
continue;
}
boolean primary = primarySet.contains(identity);
MetricPointData point = buildMetricPoint(stats, result.getWarnings().get(identity), context,
dataByIdentity == null ? null : dataByIdentity.get(identity), durationSeconds,
identity.equals(primary));
primary);
// 主监测指标标记(温度等辅助量为影响量,primary=false)
point.setPrimary(identity.equals(primary));
if (identity.equals(primary)) {
primaryPoint = point;
point.setPrimary(primary);
if (primary) {
primaryPoints.add(point);
} else {
all.add(point);
}
}
// 主指标排第一
// 主指标排第一(按 primaryIdentifiers 顺序),辅助量随后
List<MetricPointData> filtered = new ArrayList<>();
if (primaryPoint != null) {
filtered.add(primaryPoint);
List<String> primaryIdentifiers = result.getPrimaryIdentifiers();
if (primaryIdentifiers != null) {
for (String id : primaryIdentifiers) {
primaryPoints.stream()
.filter(p -> id.equals(p.getIdentifier()))
.forEach(filtered::add);
}
}
// 兜底:未被 primaryIdentifiers 覆盖的主指标点(理论上不会发生)也保留
for (MetricPointData p : primaryPoints) {
if (!filtered.contains(p)) {
filtered.add(p);
}
}
filtered.addAll(all);
return filtered;
}
/**
* 判断是否为辅助指标
*/
protected boolean isAuxiliaryMetric(String modelName, String identifier) {
return primaryMetricResolver.isAuxiliary(modelName + " " + identifier);
/** 主指标标识符集合(多主指标),空则退化为单值 primaryIdentifier */
private Set<String> primaryIdentifierSet(AnalysisResult result) {
Set<String> set = new LinkedHashSet<>();
if (result.getPrimaryIdentifiers() != null && !result.getPrimaryIdentifiers().isEmpty()) {
set.addAll(result.getPrimaryIdentifiers());
} else if (StringUtils.isNotEmpty(result.getPrimaryIdentifier())) {
set.add(result.getPrimaryIdentifier());
}
return set;
}
/**
@ -530,9 +556,9 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
/**
* 解析物模型展示单位
*
* <p>{@link #backfillModelInfo} 已把空单位按<b>报告主类别</b>兜底写入了 {@code Statistics.unit},
* 因此这里必须回到物模型原始配置判断,否则水温、设备电压、角度等辅助量会被错标成主类别单位
* (如静力水准的 mm)。</p>
* <p>物模型单位存放在 {@code iot_things_model.specs} JSON 的 {@code unit} 字段里,
* 实体 getter {@code ThingsModel.getUnit()} 对应列在表中并不存在(恒为 null),
* 必须走 {@link ValueFormatter#unitOf(ThingsModel)}。</p>
*
* <ul>
* <li>物模型配置了单位:直接使用;</li>
@ -541,10 +567,9 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
* </ul>
*/
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();
String modelUnit = ValueFormatter.unitOf(findModelByIdentifier(context, stats.getIdentifier()));
if (StringUtils.isNotEmpty(modelUnit)) {
return modelUnit;
}
if (!primaryMetric) {
return "";
@ -680,6 +705,10 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
.collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList()));
Map<String, byte[]> charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity);
result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts));
// 多主指标:每个主指标各出一组三张曲线(时程/累计变化量/变化速率)
result.setMetricChartGroups(metricChartBuilder.buildGroups(
result.getStatistics(), result.getPrimaryIdentifiers(), byIdentity));
}
/**

View File

@ -26,7 +26,15 @@ public class AxialForceAnalyzer extends AbstractDeviceAnalyzer {
@Override
protected String[] primaryMetricKeywords() {
return new String[]{"轴力", "应力", "force", "stress"};
// 该类别的力值物模型(设备级,各设备自行定义)命名与标识符都不统一,
// 现场实测同一产品下并存三种形态:
// 设备 536:MSJ_KN「压力」 设备 565:KN「压力」 设备 742:KN「锚力」
// 因此关键词必须同时覆盖"名称"和"标识符"两个维度,且不能枚举具体写法:
// "力" 覆盖全部中文命名(轴力/应力/压力/锚力/锚索力…);
// "kn" 覆盖千牛这一单位量纲的标识符(KN / MSJ_KN / kN…)。
// 产品内的干扰量(频率CH1..4、电压、温度、GPS 经度/纬度)两个维度都不命中,
// 且辅助量在 PrimaryMetricResolver 里已被排除,不会误选。
return new String[]{"力", "kn", "force", "stress"};
}
@Override

View File

@ -81,6 +81,59 @@ public class MetricChartBuilder {
return charts;
}
/**
* 构建多主指标的图表组(每个主指标一组三张曲线,有序)
*
* <p>与 {@link #build} 的区别:主指标可能有多个({@code primaryIdentifiers}),
* 每个主指标各出一组「时程曲线 + 累计变化量 + 变化速率」,图标题带指标名区分。</p>
*
* @param statisticsMap 全部指标统计(key = 物模型标识符)
* @param primaryIdentifiers 主指标标识符列表(有序,全部主指标)
* @param byIdentity 指标 → 时序数据
* @return 有序图表组列表(与 primaryIdentifiers 对齐,仅含可出图的主指标)
*/
public java.util.List<com.fastbee.report.model.MetricChartGroup> buildGroups(
Map<String, Statistics> statisticsMap,
java.util.List<String> primaryIdentifiers,
Map<String, List<MetricData>> byIdentity) {
java.util.List<com.fastbee.report.model.MetricChartGroup> groups = new java.util.ArrayList<>();
if (statisticsMap == null || statisticsMap.isEmpty()) {
return groups;
}
java.util.List<String> ids = (primaryIdentifiers == null || primaryIdentifiers.isEmpty())
? java.util.Collections.singletonList(statisticsMap.keySet().iterator().next())
: primaryIdentifiers;
for (String identifier : ids) {
Statistics stats = statisticsMap.get(identifier);
if (stats == null) {
continue;
}
String name = stats.getModelName() == null ? identifier : stats.getModelName();
String unit = stats.getUnit() == null ? "" : stats.getUnit();
List<MetricData> series = sortedSeries(byIdentity.getOrDefault(identifier, new ArrayList<>()));
if (series.isEmpty()) {
continue;
}
com.fastbee.report.model.MetricChartGroup group = new com.fastbee.report.model.MetricChartGroup();
group.setIdentifier(identifier);
group.setName(name);
group.setUnit(unit);
group.setTimeSeries(chartGenerator.timeSeriesChart(
name + "时程曲线", name, unit, series));
BigDecimal initial = series.get(0).getNumericValue();
group.setCumulative(chartGenerator.timeSeriesChart(
name + "累计变化量曲线", name + "累计变化量", unit, cumulativeSeries(series, initial)));
group.setRate(chartGenerator.timeSeriesChart(
name + "变化速率曲线", name + "变化速率",
unit + "/d", rateCalculator.segmentRateSeries(series)));
groups.add(group);
}
return groups;
}
private List<MetricData> sortedSeries(List<MetricData> data) {
List<MetricData> sorted = new ArrayList<>();
if (data == null) {

View File

@ -5,6 +5,7 @@ import com.fastbee.report.chart.ChartGenerator;
import com.fastbee.report.data.MetricData;
import com.fastbee.report.model.AnalysisResult;
import com.fastbee.report.model.Conclusion;
import com.fastbee.report.model.MetricChartGroup;
import com.fastbee.report.model.MetricPointData;
import com.fastbee.report.model.Statistics;
import com.fastbee.report.model.WarningAnalysis;
@ -104,6 +105,8 @@ public class MultiDeviceAnalysisMerger {
AnalysisResult merged = new AnalysisResult();
merged.setPrimaryIdentifier(base.getPrimaryIdentifier());
// 多主指标:沿用最不利测点的主指标集合(同一产品各测点物模型一致,主指标集合也应一致)
merged.setPrimaryIdentifiers(base.getPrimaryIdentifiers());
merged.setSummary(base.getSummary());
merged.setProfessionalAdvice(base.getProfessionalAdvice());
merged.setSafetyLevel(base.getSafetyLevel());
@ -121,9 +124,79 @@ public class MultiDeviceAnalysisMerger {
merged.setConclusions(buildConclusions(base, valid));
merged.getCharts().putAll(base.getCharts());
merged.getCharts().putAll(buildMultiPointCharts(valid, base.getPrimaryIdentifier()));
// 多主指标图表组:每个主指标一组(多测点时各测点一条曲线)
merged.setMetricChartGroups(buildMultiPointGroups(valid, base));
return appendSkipped(merged, skippedPoints, totalPoints);
}
/**
* 构建多主指标的图表组(多测点:每个主指标一组,组内各测点一条曲线)
*
* <p>与单设备 {@code MetricChartBuilder#buildGroups} 的差异在于「测点维度」:
* 多测点时组内三张图(时程/累计变化量/变化速率)各是多曲线(每测点一条),
* 以便横向对比各测点是否同步发展。主指标维度则完整保留(每个主指标一组)。</p>
*/
private List<MetricChartGroup> buildMultiPointGroups(List<DeviceAnalysis> analyses, AnalysisResult base) {
List<MetricChartGroup> groups = new ArrayList<>();
List<String> primaryIdentifiers = base.getPrimaryIdentifiers();
if (primaryIdentifiers == null || primaryIdentifiers.isEmpty()) {
return groups;
}
for (String identifier : primaryIdentifiers) {
// 各测点该主指标的时序数据(label = 测点标识,value = 序列)
Map<String, List<MetricData>> seriesMap = new LinkedHashMap<>();
String unit = null;
String name = identifier;
for (DeviceAnalysis item : analyses) {
List<MetricData> series = seriesOf(item, identifier);
if (series.isEmpty()) {
continue;
}
seriesMap.put(pointLabel(item), series);
if (unit == null) {
unit = series.get(0).getUnit();
}
Statistics stats = item.getResult().getStatistics().get(identifier);
if (stats != null && stats.getModelName() != null) {
name = stats.getModelName();
}
}
if (seriesMap.isEmpty()) {
continue;
}
String unitText = unit == null ? "" : unit;
MetricChartGroup group = new MetricChartGroup();
group.setIdentifier(identifier);
group.setName(name);
group.setUnit(unitText);
group.setTimeSeries(chartGenerator.multiSeriesChart(
name + "时程曲线", unitText, seriesMap));
// 累计变化量:各测点相对各自初始值
Map<String, List<MetricData>> cumulativeMap = new LinkedHashMap<>();
for (Map.Entry<String, List<MetricData>> entry : seriesMap.entrySet()) {
List<MetricData> s = entry.getValue();
if (!s.isEmpty() && s.get(0).getNumericValue() != null) {
cumulativeMap.put(entry.getKey(), toCumulative(s, s.get(0).getNumericValue()));
}
}
group.setCumulative(chartGenerator.multiSeriesChart(
name + "累计变化量曲线", unitText, cumulativeMap));
// 变化速率:各测点分段速率
Map<String, List<MetricData>> rateMap = new LinkedHashMap<>();
for (Map.Entry<String, List<MetricData>> entry : seriesMap.entrySet()) {
rateMap.put(entry.getKey(), rateCalculator.segmentRateSeries(entry.getValue()));
}
group.setRate(chartGenerator.multiSeriesChart(
name + "变化速率曲线", unitText + "/d", rateMap));
groups.add(group);
}
return groups;
}
/**
* 把"统计期内无数据、未纳入分析"的测点写入结论。
*

View File

@ -54,6 +54,9 @@ public class ReportAssembler {
primary = analysis.getStatistics().values().stream().findFirst().orElse(null);
}
// 多主指标:收集全部主指标名称(水位、水温…),供头部「监测指标」字段并列展示
java.util.List<String> metricNames = collectMetricNames(analysis, primary);
// 添加监测分类描述
String categoryDesc = context.getCategory().getDesc() + "监测分析报告";
@ -71,8 +74,31 @@ public class ReportAssembler {
.category(context.getCategory())
.categoryDesc(categoryDesc) // 添加分类描述
.metricName(primary == null ? null : primary.getModelName())
.metricNames(metricNames)
.metricUnit(primary == null ? null : primary.getUnit())
.analysis(analysis)
.build();
}
/**
* 收集全部主监测指标名称(有序)
*
* <p>多主指标(primaryIdentifiers 非空且 &gt;1)时返回全部名称;否则退化为单值 metricName。</p>
*/
private java.util.List<String> collectMetricNames(AnalysisResult analysis, Statistics primary) {
java.util.List<String> names = new java.util.ArrayList<>();
java.util.List<String> ids = analysis.getPrimaryIdentifiers();
if (ids != null && !ids.isEmpty()) {
for (String id : ids) {
Statistics s = analysis.getStatistics().get(id);
if (s != null && StringUtils.isNotEmpty(s.getModelName())) {
names.add(s.getModelName());
}
}
}
if (names.isEmpty() && primary != null && StringUtils.isNotEmpty(primary.getModelName())) {
names.add(primary.getModelName());
}
return names;
}
}

View File

@ -12,6 +12,7 @@ import lombok.Data;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 报告上下文(设备/产品/物模型/时序数据/阈值一次性装配)
@ -49,6 +50,18 @@ public class ReportContext {
/** 阈值(key = 物模型标识符) */
private Map<String, ThingsThreshold> thresholds;
/**
* 用户显式指定为主监测指标的物模型标识符(来自 iot_things_threshold.is_primary)
*
* <p>级别口径与阈值数值同一套(逐标识符 {@code device > product}):设备级配了该物模型就以
* 设备行为准(设备行没勾、产品行勾了也不作数),设备没配才继承产品行的勾选。
* 由 {@code ReportContextBuilder#resolveExplicitPrimary} 从 {@link #thresholds} 提取。</p>
*
* <p>为空/为 null 表示用户未配置,主指标回退到 {@code PrimaryMetricResolver} 的关键词推断。
* 非空时优先级高于关键词推断。</p>
*/
private Set<String> explicitPrimaryIdentifiers;
private String reportNo;
/** 创建人用户名(从主线程传递,避免异步线程无法访问 SecurityContext) */
@ -56,4 +69,13 @@ public class ReportContext {
/** 租户ID(从主线程传递) */
private Long tenantId;
/**
* 该物模型是否被用户显式指定为主监测指标
*/
public boolean isPrimaryMetric(String identifier) {
return identifier != null
&& explicitPrimaryIdentifiers != null
&& explicitPrimaryIdentifiers.contains(identifier);
}
}

View File

@ -20,7 +20,9 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -91,6 +93,8 @@ public class ReportContextBuilder {
modelQuery.setType(1);
modelQuery.setIsMonitor(1);
List<ThingsModel> models = thingsModelService.selectThingsModelList(modelQuery);
// 同一产品下各设备可能各自定义设备级物模型(device_id>0),先收窄到本设备的
models = filterByDevice(models, deviceId);
// 前端勾选的物模型:只分析所选物理量;未勾选(空)表示分析产品下全部监测物模型
models = filterByIdentifiers(models, request.getIdentifiers(), deviceId);
@ -119,6 +123,15 @@ public class ReportContextBuilder {
}
}
// 用户显式指定的主监测指标(阈值配置页「主监测指标」开关)
//
// 逐标识符解析,口径与阈值数值完全一致(selectByPriority 的 device > product):
// 设备配了该物模型 → 以设备行为准(哪怕设备行没勾、产品行勾了,也不作数)
// 设备没配该物模型 → 继承产品行的勾选
// 因此直接从上面按物模型查回来的 thresholds 里取,不能再单独查一次"全部标记"——
// 那样会把产品级的勾选强加到"设备已自行配置"的物模型上,与阈值数值口径打架。
Set<String> explicitPrimaryIdentifiers = resolveExplicitPrimary(thresholds);
ReportContext context = new ReportContext();
context.setRequest(request);
context.setTimeWindow(window);
@ -129,6 +142,7 @@ public class ReportContextBuilder {
context.setMetricDataList(metricDataList);
context.setBaselineMetricDataList(baselineMetricDataList);
context.setThresholds(thresholds);
context.setExplicitPrimaryIdentifiers(explicitPrimaryIdentifiers);
context.setReportNo(reportNo);
// 从主线程获取用户信息,传递到异步线程
@ -151,6 +165,54 @@ public class ReportContextBuilder {
return context;
}
/**
* 从「按物模型查回的阈值行」里取出用户显式勾选的监控指标标识符
*
* <p>{@code thresholds} 的每个 value 都是 {@code ThingsThresholdProvider#find} 的结果,
* 即 `selectByPriority` 的 {@code device > product} 逐标识符解析:
* 设备级有行就返回设备级行,否则返回产品级行。所以这里直接读 {@code is_primary}
* 就是用户要的口径:<b>设备配了以设备为准,设备没配就继承产品</b>。</p>
*
* <p>关键:<b>不能</b>另起一条"查全部标记行"的查询。那样会把产品级的勾选强加到
* 「设备已自行配置(设备行存在但没勾)」的物模型上,出现"标记说它是主指标、
* 阈值数值却取的是设备行"的自相矛盾状态。</p>
*/
static Set<String> resolveExplicitPrimary(Map<String, ThingsThreshold> thresholds) {
if (thresholds == null || thresholds.isEmpty()) {
return Collections.emptySet();
}
return thresholds.entrySet().stream()
.filter(entry -> Boolean.TRUE.equals(entry.getValue().getIsPrimary()))
.map(Map.Entry::getKey)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
* 收窄到"产品级物模型 + 本设备的设备级物模型"
*
* <p>物模型查询只按 {@code productId} 过滤(不带 deviceId),因此会带出同一产品下
* <b>其他设备</b>自行定义的设备级物模型({@code iot_things_model.device_id > 0})。
* 这些物模型的命名与单位都可能与本设备不同 —— 现场实例:同一产品的力值量,
* 设备 536 叫"压力"({@code MSJ_KN})、设备 742 叫"锚力"({@code KN})。</p>
*
* <p>不收窄会带来两个后果:① 候选集被别的设备的私有变量稀释,主指标关键词匹配更易落空;
* ② 若两个设备用了相同 identifier,{@code AbstractDeviceAnalyzer#getAllModels} 的
* {@code Collectors.toMap} 会由后者覆盖前者,报告里的名称/单位会张冠李戴。</p>
*
* <p>{@code device_id} 为空或 0 视为产品级物模型,任何设备都适用,予以保留。</p>
*/
List<ThingsModel> filterByDevice(List<ThingsModel> models, Long deviceId) {
if (models == null || models.isEmpty()) {
return models;
}
return models.stream()
.filter(model -> {
Long owner = model.getDeviceId();
return owner == null || owner == 0L || owner.equals(deviceId);
})
.collect(Collectors.toList());
}
/**
* 按请求指定的物模型标识符过滤监测物模型
*

View File

@ -6,6 +6,7 @@ import com.fastbee.iot.domain.DeviceLog;
import com.fastbee.iot.domain.ThingsModel;
import com.fastbee.iot.model.HistoryModel;
import com.fastbee.iot.service.IDeviceLogService;
import com.fastbee.report.analysis.ValueFormatter;
import com.fastbee.report.period.TimeWindow;
import org.springframework.stereotype.Component;
@ -72,7 +73,8 @@ public class ReportDataProvider {
ThingsModel model = modelMap.get(history.getIdentity());
if (model != null) {
data.setModelName(model.getModelName());
data.setUnit(model.getUnit());
// 单位存在 specs JSON 中,实体 getter 取不到(见 ValueFormatter#unitOf)
data.setUnit(ValueFormatter.unitOf(model));
}
result.add(data);
}

View File

@ -14,6 +14,9 @@ public interface ThingsThresholdMapper extends BaseMapper<ThingsThreshold> {
/**
* 按优先级查询阈值:device_id > product_id > 全局默认(product_id=0)
*
* <p>逐物模型(标识符)解析,返回的整行里同时带出 {@code is_primary}(主监测指标标记)。
* 主指标的级别口径与阈值数值完全一致:设备级有行即以设备行为准,否则继承产品级。</p>
*/
ThingsThreshold selectByPriority(@Param("deviceId") Long deviceId,
@Param("productId") Long productId,

View File

@ -17,9 +17,26 @@ import java.util.Map;
@Data
public class AnalysisResult {
/** 主监测指标标识符(统一由 Analyzer 解析,ReportAssembler/WordRenderer 共用) */
/**
* 主监测指标标识符(统一由 Analyzer 解析,ReportAssembler/WordRenderer 共用)
*
* <p><b>单值语义:</b>在多个主指标中代表「主导主指标」——用于单值章节
* (项目概况 / 结论 / 安全等级 / 控制值 / 时程曲线主图)。取值口径:
* 预警等级最高者优先,同等级取变化量绝对值最大者(与多测点「最不利测点」一致)。</p>
*/
private String primaryIdentifier;
/**
* 全部主监测指标标识符(有序,来自 PrimaryMetricResolver 的多主指标解析)
*
* <p>用户可显式勾选多个 {@code is_primary=1} 物模型(如静力水准仪的「水位」与「水温」),
* 它们应并列作为报告的监测指标,而非只取一个。头部「监测指标」字段与测点表
* 据此渲染多个主指标(各出完整四列),而单值章节仍以 {@link #primaryIdentifier} 为主。</p>
*
* <p>为空列表/null 时视为未解析,兼容旧逻辑回退到 {@link #primaryIdentifier}。</p>
*/
private List<String> primaryIdentifiers;
/** 项目概况总结(开头段落,概括关键数据) */
private String summary;
@ -58,4 +75,13 @@ public class AnalysisResult {
/** 图表数据:key = 模板占位符(如 crackTrend),value = PNG byte[] */
private Map<String, byte[]> charts = new LinkedHashMap<>();
/**
* 每个主监测指标的一组图表(有序,与 primaryIdentifiers 对齐)
*
* <p>多主指标时每个主指标各出一组三张曲线(时程/累计变化量/变化速率),
* 由 {@code MetricChartBuilder} 生成、{@code WordRenderer} 用图片列表循环渲染。
* 单主指标时列表仅一个元素,行为与旧版一致。</p>
*/
private List<MetricChartGroup> metricChartGroups = new ArrayList<>();
}

View File

@ -0,0 +1,34 @@
package com.fastbee.report.model;
import lombok.Data;
/**
* 一个主监测指标的一组图表(时程曲线 + 累计变化量 + 变化速率)
*
* <p>多主指标报告按主指标分组生成图表:每个主指标各出一组三张曲线,
* 图标题带指标名区分(如「水位时程曲线」「水温时程曲线」)。</p>
*
* @author FastBee
* @date 2026-09-26
*/
@Data
public class MetricChartGroup {
/** 主指标标识符 */
private String identifier;
/** 主指标名称(物模型中文名,用于图标题) */
private String name;
/** 单位(用于纵轴标注) */
private String unit;
/** 时程曲线 PNG */
private byte[] timeSeries;
/** 累计变化量曲线 PNG */
private byte[] cumulative;
/** 变化速率曲线 PNG */
private byte[] rate;
}

View File

@ -47,6 +47,14 @@ public class ReportModel {
/** 主监测指标(裂缝宽度)名称 */
private String metricName;
/**
* 全部主监测指标名称(有序,与 AnalysisResult.primaryIdentifiers 对应)
*
* <p>单主指标时与 {@link #metricName} 等价(单元素列表);多主指标时包含所有主指标名。
* 头部「监测指标」字段据此渲染多个名称。为空时回退 {@link #metricName}。</p>
*/
private java.util.List<String> metricNames;
/** 主监测指标单位 */
private String metricUnit;

View File

@ -17,6 +17,7 @@ import com.fastbee.report.analysis.ValueFormatter;
import com.fastbee.report.exception.ReportException;
import com.fastbee.report.model.AnalysisResult;
import com.fastbee.report.model.Conclusion;
import com.fastbee.report.model.MetricChartGroup;
import com.fastbee.report.model.MetricPointData;
import com.fastbee.report.model.QualityAnalysis;
import com.fastbee.report.model.ReportModel;
@ -50,6 +51,7 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Word 渲染器(poi-tl 模板渲染 + JFreeChart PNG 嵌入,禁止查库)
@ -250,7 +252,7 @@ public class WordRenderer implements ReportRenderer {
device.put("name", model.getDeviceName());
device.put("serialNumber", model.getSerialNumber());
device.put("productName", model.getProductName());
device.put("metricName", model.getMetricName());
device.put("metricName", metricNameText(model));
device.put("metricUnit", model.getMetricUnit() == null ? "" : model.getMetricUnit());
Map<String, Object> statistics = new HashMap<>();
@ -370,6 +372,9 @@ public class WordRenderer implements ReportRenderer {
charts.put(entry.getKey(), picture);
}
// 多主指标图表组:每个主指标一组三张曲线(时程/累计变化量/变化速率),模板区块循环渲染
List<Map<String, Object>> metricCharts = buildMetricCharts(analysis);
List<Map<String, Object>> conclusions = new ArrayList<>();
for (Conclusion conclusion : analysis.getConclusions()) {
Map<String, Object> item = new HashMap<>();
@ -382,10 +387,60 @@ public class WordRenderer implements ReportRenderer {
data.put("device", device);
data.put("analysis", analysisMap);
data.put("charts", charts);
data.put("metricCharts", metricCharts);
data.put("conclusions", conclusions);
return data;
}
/**
* 构建多主指标图表组数据(模板 {{?metricCharts}} 区块循环消费)
*
* <p>每个主指标一个 Map,含 {@code timeSeries} / {@code cumulative} / {@code rate}
* 三个图片(对应模板里 {{@timeSeries}} / {{@cumulative}} / {{@rate}})。</p>
*
* <p>兜底:{@code metricChartGroups} 为空(如旧链路未生成)时回退到
* {@code charts} 里的 timeSeriesChart/chart2/chart3 单组,保证兼容。</p>
*/
private List<Map<String, Object>> buildMetricCharts(AnalysisResult analysis) {
List<Map<String, Object>> result = new ArrayList<>();
List<MetricChartGroup> groups = analysis.getMetricChartGroups();
if (groups != null && !groups.isEmpty()) {
for (MetricChartGroup group : groups) {
Map<String, Object> item = new HashMap<>();
item.put("timeSeries", picture(group.getTimeSeries()));
item.put("cumulative", picture(group.getCumulative()));
item.put("rate", picture(group.getRate()));
result.add(item);
}
return result;
}
// 兜底:从 charts Map 拼一组(主导主指标),保证旧模板/旧数据不报错
byte[] timeSeries = analysis.getCharts() == null ? null : analysis.getCharts().get("timeSeriesChart");
byte[] cumulative = analysis.getCharts() == null ? null : analysis.getCharts().get("chart2");
byte[] rate = analysis.getCharts() == null ? null : analysis.getCharts().get("chart3");
if (timeSeries != null || cumulative != null || rate != null) {
Map<String, Object> item = new HashMap<>();
item.put("timeSeries", picture(timeSeries));
item.put("cumulative", picture(cumulative));
item.put("rate", picture(rate));
result.add(item);
}
return result;
}
/** 字节数组 → 图片渲染数据(统一尺寸 600x240,适配 A4) */
private ByteArrayPictureRenderData picture(byte[] png) {
if (png == null || png.length == 0) {
return null;
}
ByteArrayPictureRenderData picture = new ByteArrayPictureRenderData(png, PictureType.PNG);
PictureStyle style = new PictureStyle();
style.setWidth(600);
style.setHeight(240);
picture.setPictureStyle(style);
return picture;
}
/**
* 解析主监测指标统计:优先 primaryIdentifier,兜底第一个
*/
@ -399,6 +454,26 @@ public class WordRenderer implements ReportRenderer {
return analysis.getStatistics().values().stream().findFirst().orElse(null);
}
/**
* 头部「监测指标」字段文本:多主指标并列,用「、」连接;单指标退回原值
*/
private String metricNameText(ReportModel model) {
List<String> names = model.getMetricNames();
if (names != null && !names.isEmpty()) {
// 去重且保序,避免同名主指标重复显示
List<String> dedup = new ArrayList<>();
for (String name : names) {
if (name != null && !name.trim().isEmpty() && !dedup.contains(name.trim())) {
dedup.add(name.trim());
}
}
if (!dedup.isEmpty()) {
return String.join("、", dedup);
}
}
return model.getMetricName() == null ? "" : model.getMetricName();
}
/**
* 构建动态列测点表(模板占位 {{metricTable}})
*
@ -432,9 +507,19 @@ public class WordRenderer implements ReportRenderer {
// 2. 列顺序:主指标优先,其余按首次出现顺序(全测点统一,便于横向对比)
List<String> orderedIdentifiers = new ArrayList<>();
Set<String> primarySet = new java.util.LinkedHashSet<>();
if (analysis.getPrimaryIdentifiers() != null && !analysis.getPrimaryIdentifiers().isEmpty()) {
for (String id : analysis.getPrimaryIdentifiers()) {
if (id != null && !orderedIdentifiers.contains(id)) {
orderedIdentifiers.add(id);
primarySet.add(id);
}
}
}
String primaryIdentifier = analysis.getPrimaryIdentifier();
if (primaryIdentifier != null) {
if (primaryIdentifier != null && !orderedIdentifiers.contains(primaryIdentifier)) {
orderedIdentifiers.add(primaryIdentifier);
primarySet.add(primaryIdentifier);
}
for (MetricPointData point : points) {
if (point.getIdentifier() != null && !orderedIdentifiers.contains(point.getIdentifier())) {
@ -456,7 +541,7 @@ public class WordRenderer implements ReportRenderer {
MetricPointData sample = findSample(points, identifier);
String name = sample == null ? identifier : nvl(sample.getModelName());
String unit = sample == null ? "" : nvl(sample.getCurrentUnit());
boolean primary = identifier.equals(primaryIdentifier);
boolean primary = primarySet.contains(identifier);
if (primary) {
header.add(name + "·初始值" + unitSuffix(unit));
header.add(name + "·本次值" + unitSuffix(unit));
@ -494,14 +579,14 @@ public class WordRenderer implements ReportRenderer {
MetricPointData point = byIdentifier.get(identifier);
if (point == null) {
row.add("-");
if (identifier.equals(primaryIdentifier)) {
if (primarySet.contains(identifier)) {
row.add("-");
row.add("-");
row.add("-");
}
continue;
}
if (identifier.equals(primaryIdentifier)) {
if (primarySet.contains(identifier)) {
row.add(strForTable(point.getInitialValue(), category));
row.add(strForTable(point.getCurrentValue(), category));
row.add(strForTable(point.getChangeValue(), category));

View File

@ -1,6 +1,5 @@
package com.fastbee.report.threshold;
import com.fastbee.common.utils.StringUtils;
import com.fastbee.report.mapper.ThingsThresholdMapper;
import org.springframework.stereotype.Service;

View File

@ -50,6 +50,19 @@ public class ThingsThreshold {
private String unit;
/**
* 是否主监测指标(1=是,0/NULL=否,由关键词推断)
*
* <p>报告里的「监测指标」默认由 {@code PrimaryMetricResolver} 按关键词推断,
* 命名不规范时会误判(现场曾把 GPS「经度」选成轴力计的监测指标)。
* 这里提供显式指定的能力:被标记的物模型,优先级高于任何关键词推断。</p>
*
* <p>级别口径与阈值数值**完全一致**({@code selectByPriority} 的 device &gt; product,
* 逐标识符解析):某个物模型设备级配了就以设备行为准 —— 设备行没勾、产品行勾了也不作数;
* 设备级没配才继承产品行的勾选。</p>
*/
private Boolean isPrimary;
@TableField(fill = FieldFill.INSERT)
private String createBy;

View File

@ -10,6 +10,9 @@ public interface ThingsThresholdProvider {
/**
* 查询指定设备 + 物模型标识符的结构化阈值(device > product > 全局)
*
* <p>返回行的 {@code isPrimary} 即"该物模型被用户显式指定为主监测指标"。
* 级别口径与阈值数值同一套:设备级有行即以设备行为准,设备没配就继承产品级的勾选。</p>
*/
ThingsThreshold find(Long deviceId, Long productId, String categoryCode, String metricIdentifier);
}

View File

@ -4,11 +4,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.fastbee.report.mapper.ThingsThresholdMapper">
<!-- 阈值优先级:设备级(device_id) > 产品级(product_id)。无全局默认层。 -->
<!-- 阈值优先级:设备级(device_id) > 产品级(product_id)。无全局默认层。
is_primary(主监测指标)也走这条查询逐物模型取值:
设备级有行即以设备行为准,否则继承产品级 —— 与阈值数值同一套优先级。 -->
<select id="selectByPriority" resultType="com.fastbee.report.threshold.ThingsThreshold">
select id, product_id, device_id, monitor_category_code, metric_identifier,
warning_value, alarm_value, control_value,
warning_rate, alarm_rate, control_rate, unit,
warning_rate, alarm_rate, control_rate, unit, is_primary,
create_by, create_time, update_by, update_time
from iot_things_threshold
<where>

View File

@ -1,93 +0,0 @@
<template>
<el-select
v-model="innerValue"
:placeholder="placeholder"
:clearable="clearable"
:filterable="filterable"
:remote="remote"
:remote-method="remoteSearch"
:loading="loading"
:disabled="disabled"
style="width: 100%"
@change="handleChange"
>
<el-option
v-for="item in optionList"
:key="item[valueKey]"
:label="formatLabel(item)"
:value="item[valueKey]"
/>
</el-select>
</template>
<script>
export default {
name: 'PlatformEntitySelect',
props: {
value: { type: [String, Number], default: null },
loader: { type: Function, required: true },
placeholder: { type: String, default: '请选择' },
valueKey: { type: String, default: 'id' },
labelKey: { type: String, default: 'name' },
subLabelKey: { type: String, default: '' },
query: { type: Object, default: () => ({}) },
clearable: { type: Boolean, default: true },
filterable: { type: Boolean, default: true },
remote: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
},
data() {
return {
innerValue: this.value,
loading: false,
optionList: [],
};
},
watch: {
value(value) {
this.innerValue = value;
},
query: {
deep: true,
handler() {
this.loadOptions();
},
},
},
created() {
this.loadOptions();
},
methods: {
loadOptions(keyword) {
this.loading = true;
const params = Object.assign({ pageNum: 1, pageSize: 100 }, this.query);
if (keyword) {
params.keyword = keyword;
params.name = keyword;
params.platformName = keyword;
params.channelName = keyword;
params.flowName = keyword;
}
this.loader(params).then(response => {
this.optionList = response.rows || response.data || [];
this.loading = false;
}).catch(() => {
this.loading = false;
});
},
remoteSearch(keyword) {
this.loadOptions(keyword);
},
formatLabel(item) {
const main = item[this.labelKey] || item.name || item.label || item[this.valueKey];
const sub = this.subLabelKey ? item[this.subLabelKey] : '';
return sub ? `${main}(${sub})` : main;
},
handleChange(value) {
const selected = this.optionList.find(item => item[this.valueKey] === value);
this.$emit('input', value);
this.$emit('change', value, selected);
},
},
};
</script>

View File

@ -1,217 +0,0 @@
<template>
<div class="http-request-designer">
<div class="request-url-bar">
<el-select v-model="designer.method" class="request-method" placeholder="请求方法">
<el-option v-for="item in methodOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-input
v-model="designer.pathTemplate"
class="request-path"
placeholder="请输入接口路径,例如 /api/v1/data"
/>
</div>
<el-tabs v-model="activeTab" class="request-tabs">
<el-tab-pane label="Params" name="params">
<key-value-editor ref="paramsEditor" v-model="designer.params" add-text="添加 Query 参数" />
</el-tab-pane>
<el-tab-pane label="Headers" name="headers">
<key-value-editor ref="headersEditor" v-model="designer.headers" add-text="添加 Header" />
</el-tab-pane>
<el-tab-pane label="Body" name="body">
<el-radio-group v-model="designer.body.mode" size="small" class="body-mode">
<el-radio-button label="none">none</el-radio-button>
<el-radio-button label="form-data">form-data</el-radio-button>
<el-radio-button label="x-www-form-urlencoded">x-www-form-urlencoded</el-radio-button>
<el-radio-button label="raw-json">raw JSON</el-radio-button>
</el-radio-group>
<key-value-editor
v-if="designer.body.mode === 'form-data'"
ref="formDataEditor"
v-model="designer.body.formData"
add-text="添加 Form 字段"
/>
<key-value-editor
v-if="designer.body.mode === 'x-www-form-urlencoded'"
ref="urlencodedEditor"
v-model="designer.body.urlencoded"
add-text="添加表单字段"
/>
<div v-if="designer.body.mode === 'raw-json'">
<div style="margin-bottom:6px;text-align:right">
<el-button size="mini" icon="el-icon-upload2" @click="importJsonDialogVisible = true">导入 JSON</el-button>
</div>
<el-input v-model="designer.body.raw" type="textarea" :rows="8" placeholder="请输入 JSON 请求体,可使用 ${变量名} 作为模板变量" />
</div>
<el-alert v-if="designer.body.mode === 'none'" title="该接口不配置请求体" type="info" :closable="false" />
</el-tab-pane>
<el-tab-pane label="高级配置" name="config">
<div class="config-panel">
<div class="config-field">
<label class="config-label">Content-Type</label>
<el-select
v-model="designer.contentType"
clearable
placeholder="请选择 Content-Type"
class="config-control"
>
<el-option label="application/json" value="application/json" />
<el-option label="application/x-www-form-urlencoded" value="application/x-www-form-urlencoded" />
<el-option label="multipart/form-data" value="multipart/form-data" />
<el-option label="text/plain" value="text/plain" />
</el-select>
</div>
<div class="config-field config-field--stack">
<label class="config-label">请求配置</label>
<div class="config-control">
<key-value-editor ref="configEditor" v-model="designer.config" add-text="添加配置" />
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="保存预览" name="preview">
<pre class="preview-json">{{ previewText }}</pre>
</el-tab-pane>
</el-tabs>
<el-dialog title="导入 JSON" :visible.sync="importJsonDialogVisible" width="600px" append-to-body>
<el-input v-model="importJsonText" type="textarea" :rows="12" placeholder="粘贴 JSON 内容,支持 ${变量名} 模板变量" />
<div v-if="importJsonError" style="color:#f56c6c;margin-top:6px;font-size:12px">{{ importJsonError }}</div>
<div slot="footer">
<el-button @click="importJsonDialogVisible = false">取 消</el-button>
<el-button type="primary" @click="confirmImportJson">确 定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import KeyValueEditor from './KeyValueEditor';
import { serializeDesigner } from '../utils/requestTemplate';
export default {
name: 'PlatformHttpRequestDesigner',
components: { KeyValueEditor },
props: {
value: { type: Object, required: true },
},
data() {
return {
activeTab: 'params',
methodOptions: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
importJsonDialogVisible: false,
importJsonText: '',
importJsonError: '',
};
},
methods: {
flushEditors() {
['paramsEditor', 'headersEditor', 'configEditor', 'formDataEditor', 'urlencodedEditor'].forEach(refName => {
const editor = this.$refs[refName];
if (editor && typeof editor.flushBulk === 'function') {
editor.flushBulk();
}
});
},
confirmImportJson() {
const text = this.importJsonText.trim();
if (!text) {
this.importJsonError = '内容不能为空';
return;
}
// 将模板变量临时替换为合法值再做 JSON 校验
const sanitized = text.replace(/\$\{[^}]+\}/g, '"__var__"');
try {
JSON.parse(sanitized);
} catch (e) {
this.importJsonError = 'JSON 格式不正确:' + e.message;
return;
}
this.designer.body.raw = text;
this.importJsonDialogVisible = false;
this.importJsonText = '';
this.importJsonError = '';
},
},
computed: {
designer: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
},
},
previewText() {
return JSON.stringify(serializeDesigner(this.designer), null, 2);
},
},
};
</script>
<style scoped>
.http-request-designer {
width: 100%;
box-sizing: border-box;
}
.request-url-bar {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.request-url-bar .request-method {
width: 108px;
flex-shrink: 0;
}
.request-url-bar .request-path {
flex: 1;
min-width: 0;
}
.request-tabs {
margin-top: 12px;
}
.request-tabs ::v-deep .el-tabs__header {
margin-bottom: 12px;
}
.request-tabs ::v-deep .el-tabs__content {
padding-top: 4px;
}
.config-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.config-field {
display: flex;
align-items: center;
gap: 12px;
}
.config-field--stack {
align-items: flex-start;
}
.config-field--stack .config-label {
padding-top: 8px;
}
.config-label {
flex: 0 0 100px;
font-size: 14px;
color: #606266;
text-align: right;
line-height: 32px;
}
.config-control {
flex: 1;
min-width: 0;
}
.body-mode {
margin-bottom: 12px;
}
.preview-json {
padding: 12px;
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 4px;
max-height: 320px;
overflow: auto;
}
</style>

View File

@ -1,308 +0,0 @@
<template>
<div class="key-value-editor">
<div v-if="bulkEdit" class="kv-toolbar">
<el-button type="text" size="mini" class="kv-mode-btn" @click="toggleBulkMode">
{{ bulkMode ? '键值编辑' : '批量编辑' }}
</el-button>
</div>
<div v-if="!bulkMode" class="kv-table">
<div class="kv-header" :class="rowLayoutClass">
<span v-if="showEnabled" class="col-check" />
<span class="col-key">Key</span>
<span class="col-value">Value</span>
<span v-if="showDescription" class="col-desc">Description</span>
<span class="col-action" />
</div>
<div
v-for="(item, index) in rows"
:key="index"
class="kv-row"
:class="[rowLayoutClass, { 'is-disabled': showEnabled && item.enabled === false }]"
>
<div v-if="showEnabled" class="col-check">
<el-checkbox v-model="item.enabled" />
</div>
<div class="col-key">
<el-input v-model="item.key" size="small" :placeholder="keyPlaceholder" />
</div>
<div class="col-value">
<el-input v-model="item.value" size="small" :placeholder="valuePlaceholder" />
</div>
<div v-if="showDescription" class="col-desc">
<el-input v-model="item.description" size="small" placeholder="说明" />
</div>
<div class="col-action">
<el-button
type="text"
size="small"
class="kv-delete-btn"
icon="el-icon-delete"
@click="removeRow(index)"
/>
</div>
</div>
<div v-if="!rows.length" class="kv-empty">暂无参数,点击下方按钮添加或使用批量编辑</div>
</div>
<div v-else class="kv-bulk">
<el-input
v-model="bulkText"
type="textarea"
:rows="bulkRows"
:placeholder="bulkPlaceholder"
@blur="syncBulkToRows"
/>
<p v-if="bulkError" class="kv-bulk-error">{{ bulkError }}</p>
<p class="kv-bulk-hint">{{ bulkHint }}</p>
</div>
<div v-if="!bulkMode" class="kv-footer">
<el-button type="text" size="mini" icon="el-icon-plus" class="kv-add-btn" @click="addRow">
{{ addText }}
</el-button>
</div>
</div>
</template>
<script>
import { bulkTextToRows, rowsToBulkText } from '../utils/requestTemplate';
export default {
name: 'PlatformKeyValueEditor',
props: {
value: { type: Array, default: () => [] },
addText: { type: String, default: '添加参数' },
keyPlaceholder: { type: String, default: '参数名' },
valuePlaceholder: { type: String, default: '参数值' },
showEnabled: { type: Boolean, default: true },
showDescription: { type: Boolean, default: true },
bulkEdit: { type: Boolean, default: true },
bulkRows: { type: Number, default: 10 },
bulkPlaceholder: {
type: String,
default: '每行一个参数,格式 key:value\n以 # 开头的行表示禁用,例如:\n#disabledKey:value\nactiveKey:${token}',
},
bulkHint: {
type: String,
default: '每行格式为 key:value,仅按第一个冒号分割;以 # 开头的行在保存时视为禁用项。',
},
},
data() {
return {
bulkMode: false,
bulkText: '',
bulkError: '',
};
},
computed: {
rows: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
},
},
rowLayoutClass() {
return {
'no-desc': !this.showDescription,
'no-check': !this.showEnabled,
};
},
},
beforeDestroy() {
if (this._bulkTimer) {
clearTimeout(this._bulkTimer);
}
},
methods: {
/** 提交表单前调用,将批量编辑内容写回 v-model */
flushBulk() {
if (!this.bulkMode) {
return;
}
this.applyBulkText(true);
},
toggleBulkMode() {
if (this.bulkMode) {
if (!this.applyBulkText()) {
return;
}
this.bulkMode = false;
this.bulkError = '';
return;
}
this.bulkText = rowsToBulkText(this.rows);
this.bulkError = '';
this.bulkMode = true;
},
syncBulkToRows() {
if (!this.bulkMode) {
return;
}
this.applyBulkText(true);
},
applyBulkText(silent) {
const parsed = bulkTextToRows(this.bulkText);
if (!silent && this.bulkText.trim() && !parsed.length) {
this.bulkError = '未能解析出有效参数,请检查每行是否为 key:value 格式';
return false;
}
const descMap = {};
(this.rows || []).forEach(row => {
if (row && row.key) {
descMap[row.key] = row.description || '';
}
});
parsed.forEach(row => {
row.description = descMap[row.key] || '';
});
this.rows = parsed.length ? parsed : [];
this.bulkError = '';
return true;
},
addRow() {
const rows = this.rows.slice();
rows.push({ enabled: true, key: '', value: '', description: '' });
this.rows = rows;
},
removeRow(index) {
const rows = this.rows.slice();
rows.splice(index, 1);
this.rows = rows;
},
},
};
</script>
<style scoped>
.key-value-editor {
width: 100%;
}
.kv-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 4px;
}
.kv-mode-btn {
padding: 0 4px;
font-size: 12px;
color: #409eff;
}
.kv-table {
border: 1px solid #e4e7ed;
border-radius: 4px;
overflow: hidden;
background: #fff;
}
.kv-header,
.kv-row {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) minmax(0, 1fr) 120px 36px;
align-items: center;
column-gap: 8px;
padding: 0 8px;
}
.kv-header.no-desc,
.kv-row.no-desc {
grid-template-columns: 36px minmax(0, 1fr) minmax(0, 1fr) 36px;
}
.kv-header.no-check,
.kv-row.no-check {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 120px 36px;
}
.kv-header.no-check.no-desc,
.kv-row.no-check.no-desc {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 36px;
}
.kv-header {
height: 36px;
background: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
font-size: 12px;
font-weight: 600;
color: #909399;
}
.kv-row {
min-height: 40px;
padding-top: 4px;
padding-bottom: 4px;
border-bottom: 1px solid #f0f2f5;
}
.kv-row:last-child {
border-bottom: none;
}
.kv-row.is-disabled .col-key ::v-deep .el-input__inner,
.kv-row.is-disabled .col-value ::v-deep .el-input__inner {
color: #c0c4cc;
}
.col-check {
display: flex;
align-items: center;
justify-content: center;
}
.col-action {
display: flex;
align-items: center;
justify-content: center;
}
.kv-delete-btn {
padding: 4px;
color: #f56c6c;
font-size: 16px;
}
.kv-delete-btn:hover {
color: #f78989;
}
.kv-empty {
padding: 16px;
text-align: center;
font-size: 12px;
color: #c0c4cc;
}
.kv-footer {
margin-top: 6px;
}
.kv-add-btn {
padding-left: 0;
color: #409eff;
}
.kv-bulk ::v-deep .el-textarea__inner {
font-family: Consolas, Monaco, 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
}
.kv-bulk-hint {
margin: 6px 0 0;
font-size: 12px;
color: #909399;
line-height: 1.5;
}
.kv-bulk-error {
margin: 6px 0 0;
font-size: 12px;
color: #f56c6c;
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

View File

@ -87,6 +87,7 @@ CREATE TABLE `iot_things_threshold` (
`alarm_rate` DECIMAL(18, 6) DEFAULT NULL COMMENT '变化速率报警值(单位/天)' ,
`control_rate` DECIMAL(18, 6) DEFAULT NULL COMMENT '变化速率控制值(单位/天)',
`unit` varchar(32) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT '单位',
`is_primary` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否主监测指标(1=报告以此物模型作为监测指标)',
`create_by` varchar(64) COLLATE utf8_unicode_ci DEFAULT '' COMMENT '创建者',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) COLLATE utf8_unicode_ci DEFAULT '' COMMENT '更新者',
@ -96,6 +97,26 @@ CREATE TABLE `iot_things_threshold` (
KEY `idx_threshold_product` (`product_id`, `metric_identifier`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='报告结构化阈值';
-- ------------------------------------------------------------
-- 2.1.1 iot_things_threshold 主监测指标列
-- 全新库:上面 CREATE TABLE 已含 is_primary 列;
-- 存量库:由下面的语句补列。用 information_schema 做存在性判断,可重复执行
--
-- 背景:「监测指标」原先只能由 PrimaryMetricResolver 按关键词推断,
-- 物模型命名不规范时会误判(现场把 GPS「经度」选成了轴力计的监测指标)。
-- 新增该列后,用户在阈值配置页显式勾选即可,显式标记优先于任何关键词推断。
-- ------------------------------------------------------------
SET @exist_col := (SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'iot_things_threshold'
AND COLUMN_NAME = 'is_primary');
SET @ddl_sql := IF(@exist_col = 0,
'ALTER TABLE `iot_things_threshold` ADD COLUMN `is_primary` tinyint(1) NOT NULL DEFAULT 0 COMMENT ''是否主监测指标(1=报告以此物模型作为监测指标)'' AFTER `unit`',
'SELECT ''skip: iot_things_threshold.is_primary already exists'' AS info');
PREPARE ddl_stmt FROM @ddl_sql;
EXECUTE ddl_stmt;
DEALLOCATE PREPARE ddl_stmt;
-- ------------------------------------------------------------
-- 2.2 Word 模板(不再建 report_template 表)
-- 模板作为代码资产放在 fastbee-report/src/main/resources/templates/

View File

@ -25,6 +25,23 @@
</template>
</el-table-column>
<el-table-column label="单位" align="center" width="80" prop="unit" />
<el-table-column align="center" width="150">
<template slot="header">
<span>主监测指标</span>
<el-tooltip placement="top" content="报告中的「监测指标」以此物模型为准;不勾选时由系统按物模型名称自动推断">
<i class="el-icon-question" style="margin-left: 2px; color: #909399" />
</el-tooltip>
</template>
<template slot-scope="scope">
<el-switch v-model="scope.row.isPrimary" :active-value="true" :inactive-value="false" />
<el-tooltip v-if="inheritedPrimary(scope.row)" placement="top" content="本设备未配置该物模型,主监测指标继承产品级的勾选">
<el-tag size="mini" type="info" style="margin-left: 4px">继承</el-tag>
</el-tooltip>
<el-tooltip v-else-if="overriddenPrimary(scope.row)" placement="top" content="产品级勾了主监测指标,但本设备已配置该物模型且未勾选 → 以本设备为准,报告不会把它作为监测指标">
<el-tag size="mini" type="warning" style="margin-left: 4px">覆盖产品</el-tag>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="预警值" align="center" width="140">
<template slot-scope="scope">
<el-input v-model="scope.row.warningValue" size="mini" type="number" placeholder="未设置" />
@ -61,6 +78,20 @@
<span v-else style="color: #c0c4cc">未配置</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="90" fixed="right">
<template slot-scope="scope">
<el-button
type="text"
size="mini"
icon="el-icon-circle-close"
:disabled="!hasAnyValue(scope.row)"
:title="hasAnyValue(scope.row) ? '清空该行主监测指标与全部阈值' : '该行没有可清空的内容'"
@click="clearRow(scope.row)"
>
清空
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
@ -72,6 +103,8 @@ import { listThreshold, addThreshold, updateThreshold, delThreshold } from '@/ap
/** 仅数值型物模型可配置阈值 */
const NUMERIC_TYPES = ['integer', 'decimal'];
/** 行内可清空的阈值字段(主监测指标开关单独处理) */
const VALUE_FIELDS = ['warningValue', 'alarmValue', 'controlValue', 'warningRate', 'alarmRate', 'controlRate'];
export default {
name: 'ThresholdConfig',
@ -107,8 +140,8 @@ export default {
computed: {
tipText() {
return this.scopeType === 'device'
? '设备阈值优先级高于产品阈值;留空则继承所属产品的阈值'
: '产品阈值对该产品下所有设备生效,可被单个设备的设备阈值覆盖';
? '设备阈值优先级高于产品阈值;留空则继承所属产品的阈值。主监测指标:本设备未配置该物模型时继承产品级的勾选,一旦本设备配置了则以本设备为准'
: '产品阈值对该产品下所有设备生效,可被单个设备的设备阈值覆盖。主监测指标:勾选后该产品下所有设备的报告都以此物模型为准(设备侧可覆盖)';
},
/** 保存阈值时落库的 deviceId:产品级为 0 */
targetDeviceId() {
@ -187,6 +220,7 @@ export default {
datatype: m.datatype,
unit: this.parseUnit(m.specs),
id: undefined,
isPrimary: false,
warningValue: undefined,
alarmValue: undefined,
controlValue: undefined,
@ -215,6 +249,7 @@ export default {
this.rows.forEach((row) => {
const t = ownMap[row.identifier];
row.id = t ? t.id : undefined;
row.isPrimary = !!(t && t.isPrimary);
row.warningValue = t && t.warningValue !== null ? t.warningValue : undefined;
row.alarmValue = t && t.alarmValue !== null ? t.alarmValue : undefined;
row.controlValue = t && t.controlValue !== null ? t.controlValue : undefined;
@ -232,24 +267,70 @@ export default {
}
});
},
/** 产品级阈值列展示:阈值值 + 产品级的主指标勾选 */
formatThreshold(t) {
if (!t) return '';
const parts = [];
if (t.warningValue !== null && t.warningValue !== undefined) parts.push('预警 ' + t.warningValue);
if (t.alarmValue !== null && t.alarmValue !== undefined) parts.push('报警 ' + t.alarmValue);
if (t.controlValue !== null && t.controlValue !== undefined) parts.push('控制 ' + t.controlValue);
if (t.isPrimary) parts.push('产品级主指标');
return parts.length ? parts.join(' / ') : '未配置';
},
/** 是否有任一阈值值 */
/**
* 主监测指标「继承」:本设备没配置该物模型(无设备行),勾选来自产品级
*
* <p>与阈值数值同一套口径:每条物模型只取一行 —— 设备级有行即以设备行为准,
* 设备级没配才用产品行。所以继承只在「本设备没配该物模型」时成立。</p>
*/
inheritedPrimary(row) {
if (this.scopeType !== 'device' || row.id || row.isPrimary === true) return false;
const parent = this.parentMap[row.identifier];
return !!(parent && parent.isPrimary);
},
/**
* 主监测指标「覆盖产品」:产品级勾了,但本设备已配置该物模型(有设备行)且未勾选
*
* <p>按设备优先口径,此时**报告不会**把该物模型作为主指标。给出提示而不是静默,
* 否则用户看到产品级仍显示「主指标」会误以为生效。</p>
*/
overriddenPrimary(row) {
if (this.scopeType !== 'device' || !row.id || row.isPrimary === true) return false;
const parent = this.parentMap[row.identifier];
return !!(parent && parent.isPrimary);
},
/** 是否有任一可落库内容(含"仅勾选主指标、不设阈值"的合法场景) */
hasAnyValue(row) {
return [
row.warningValue,
row.alarmValue,
row.controlValue,
row.warningRate,
row.alarmRate,
row.controlRate,
].some((v) => v !== undefined && v !== null && v !== '');
return (
row.isPrimary === true ||
VALUE_FIELDS.some((k) => {
const v = row[k];
return v !== undefined && v !== null && v !== '';
})
);
},
/**
* 一键清空单行:主监测指标取消勾选 + 六个阈值置空
*
* <p>只改本地行数据,与页面「编辑-保存」语义保持一致:清空后点击「保存阈值」才落库,
* 已存在的记录(row.id)会在保存时走删除,避免清空按钮直接打接口造成误删不可回退。</p>
*/
clearRow(row) {
if (!this.hasAnyValue(row)) return;
const name = row.modelName || row.identifier;
this.$confirm(
`确认清空「${name}」的主监测指标与全部阈值?清空后需点击「保存阈值」生效,已保存的记录会被删除。`,
'清空确认',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
.then(() => {
row.isPrimary = false;
VALUE_FIELDS.forEach((k) => {
this.$set(row, k, undefined);
});
this.$modal.msgSuccess('已清空,点击「保存阈值」生效');
})
.catch(() => {});
},
/** 批量保存:有值新增/更新,清空则删除已有记录 */
save() {
@ -267,6 +348,7 @@ export default {
monitorCategoryCode: this.monitorCategoryCode,
metricIdentifier: row.identifier,
unit: row.unit,
isPrimary: row.isPrimary === true,
warningValue: row.warningValue === '' ? undefined : row.warningValue,
alarmValue: row.alarmValue === '' ? undefined : row.alarmValue,
controlValue: row.controlValue === '' ? undefined : row.controlValue,