reportEngine:报告监测值修改,支持多个
This commit is contained in:
parent
f446ee15e6
commit
6f20645dd9
@ -173,6 +173,37 @@ fastbee-report/
|
|||||||
- 模板:`DEVICE_CRACK_METER_V2.docx` 等,按 `template_code`(`DEVICE_${CATEGORY}_V2`)从 classpath 的 `templates/` 目录加载(`DefaultWordTemplateResolver`)。模板与渲染逻辑强耦合,**不入库**,随代码版本发布。
|
- 模板:`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}}` 等;模板不直接绑定数据库字段。
|
- 变量:`{{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(关键决策)
|
# 4. 存储方案:MinIO(关键决策)
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import com.fastbee.report.core.ReportContext;
|
|||||||
import com.fastbee.report.data.MetricData;
|
import com.fastbee.report.data.MetricData;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -13,12 +14,30 @@ import java.util.Map;
|
|||||||
*
|
*
|
||||||
* <p>判定规则(按物模型列表顺序,保证稳定、不依赖时序数据返回顺序):</p>
|
* <p>判定规则(按物模型列表顺序,保证稳定、不依赖时序数据返回顺序):</p>
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>排除辅助指标(角度/温度/电压/电量/信号/湿度等),辅助量不作为主监测指标;</li>
|
* <li><b>用户显式指定优先</b>:{@code iot_things_threshold.is_primary=1} 的物模型直接当选,
|
||||||
* <li>优先返回名称或标识符命中核心关键词的物模型;</li>
|
* 不受辅助量黑名单与关键词限制。级别口径与阈值数值同一套(逐标识符 device > product):
|
||||||
|
* 设备级配了该物模型就以设备行为准,设备没配才继承产品级的勾选;
|
||||||
|
* 多个标记项同时有数据时,取物模型列表顺序最靠前的(与关键词命中同口径);</li>
|
||||||
|
* <li>排除辅助指标(角度/温度/电压/电量/信号/湿度/GPS 定位等),辅助量不作为主监测指标;</li>
|
||||||
|
* <li>优先返回名称或标识符命中核心关键词的物模型;<b>同类命中者中优先取该时段有数据的</b>;</li>
|
||||||
* <li>否则返回第一个有数据的非辅助指标;</li>
|
* <li>否则返回第一个有数据的非辅助指标;</li>
|
||||||
* <li>再否则返回第一个非辅助指标;最后兜底第一个物模型。</li>
|
* <li>再否则返回第一个非辅助指标;最后兜底第一个物模型。</li>
|
||||||
* </ol>
|
* </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
|
* @author FastBee
|
||||||
* @date 2026-09-10
|
* @date 2026-09-10
|
||||||
*/
|
*/
|
||||||
@ -29,15 +48,19 @@ public class PrimaryMetricResolver {
|
|||||||
private static final String[] AUXILIARY_KEYWORDS = {
|
private static final String[] AUXILIARY_KEYWORDS = {
|
||||||
"角度", "angle", "温度", "temperature", "电压", "voltage",
|
"角度", "angle", "温度", "temperature", "电压", "voltage",
|
||||||
"电量", "battery", "信号", "signal", "湿度", "humidity",
|
"电量", "battery", "信号", "signal", "湿度", "humidity",
|
||||||
"电压值", "battery_voltage"
|
"电压值", "battery_voltage",
|
||||||
|
// 采集仪自带的 GPS 定位量:描述设备位置,不是被监测的物理量
|
||||||
|
"经度", "longitude", "纬度", "latitude"
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析主监测指标标识符
|
* 解析主监测指标标识符(单值口径,不允许辅助指标作主指标)
|
||||||
*
|
*
|
||||||
* @param context 报告上下文(提供物模型列表,顺序稳定)
|
* <p>保留向后兼容:等价于 {@link #resolveAll} 后取列表第一个。</p>
|
||||||
|
*
|
||||||
|
* @param context 报告上下文(提供物模型列表,顺序稳定)
|
||||||
* @param byIdentity 时序数据分组(用于判断是否有数据)
|
* @param byIdentity 时序数据分组(用于判断是否有数据)
|
||||||
* @param keywords 核心关键词(如"位移"、"缝宽"、"轴力")
|
* @param keywords 核心关键词(如"位移"、"缝宽"、"轴力")
|
||||||
* @return 主监测指标标识符;无物模型时返回 null
|
* @return 主监测指标标识符;无物模型时返回 null
|
||||||
*/
|
*/
|
||||||
public String resolve(ReportContext context, Map<String, List<MetricData>> byIdentity, String... keywords) {
|
public String resolve(ReportContext context, Map<String, List<MetricData>> byIdentity, String... keywords) {
|
||||||
@ -45,18 +68,42 @@ public class PrimaryMetricResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析主监测指标标识符
|
* 解析主监测指标标识符(单值口径)
|
||||||
*
|
*
|
||||||
* @param allowAuxiliary 是否允许辅助指标(角度/温度等)作为主指标。
|
* @param allowAuxiliary 是否允许辅助指标(角度/温度等)作为主指标。
|
||||||
* 倾角计、温度计等设备的主监测指标本身就是"角度/温度",需传 true。
|
* 倾角计、温度计等设备的主监测指标本身就是"角度/温度",需传 true。
|
||||||
*/
|
*/
|
||||||
public String resolve(ReportContext context, Map<String, List<MetricData>> byIdentity,
|
public String resolve(ReportContext context, Map<String, List<MetricData>> byIdentity,
|
||||||
boolean allowAuxiliary, String... keywords) {
|
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();
|
List<ThingsModel> models = context.getThingsModels();
|
||||||
if (models == null || models.isEmpty()) {
|
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 firstWithData = null;
|
||||||
String firstAny = null;
|
String firstAny = null;
|
||||||
|
|
||||||
@ -68,22 +115,34 @@ public class PrimaryMetricResolver {
|
|||||||
String name = model.getModelName() == null ? identity : model.getModelName();
|
String name = model.getModelName() == null ? identity : model.getModelName();
|
||||||
String lower = (name + " " + identity).toLowerCase();
|
String lower = (name + " " + identity).toLowerCase();
|
||||||
boolean auxiliary = isAuxiliary(lower);
|
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())) {
|
if (context.isPrimaryMetric(identity)) {
|
||||||
return 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean hasData = byIdentity.containsKey(identity);
|
|
||||||
if (firstAny == null) {
|
if (firstAny == null) {
|
||||||
firstAny = identity;
|
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) {
|
if (firstWithData != null) {
|
||||||
return firstWithData;
|
return single(firstWithData);
|
||||||
}
|
}
|
||||||
if (firstAny != null) {
|
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) {
|
public boolean isAuxiliary(String nameAndIdentifier) {
|
||||||
for (String keyword : AUXILIARY_KEYWORDS) {
|
for (String keyword : AUXILIARY_KEYWORDS) {
|
||||||
@ -113,14 +223,4 @@ public class PrimaryMetricResolver {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否需要生成独立图表(辅助指标中仅温度/电压等少量关注项可跳过)
|
|
||||||
*/
|
|
||||||
public boolean isChartWorthy(String nameAndIdentifier) {
|
|
||||||
return !nameAndIdentifier.contains("电量")
|
|
||||||
&& !nameAndIdentifier.contains("battery")
|
|
||||||
&& !nameAndIdentifier.contains("信号")
|
|
||||||
&& !nameAndIdentifier.contains("signal");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
package com.fastbee.report.analysis;
|
package com.fastbee.report.analysis;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
import com.fastbee.common.enums.MonitorCategory;
|
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 org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@ -91,6 +95,38 @@ public class ValueFormatter {
|
|||||||
return (fmt != null) ? fmt.defaultUnit : "";
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 带单位的显示值
|
* 带单位的显示值
|
||||||
*
|
*
|
||||||
|
|||||||
@ -32,8 +32,10 @@ import java.util.ArrayList;
|
|||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -281,7 +283,7 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
BigDecimal controlValue = threshold == null ? null : threshold.getControlValue();
|
BigDecimal controlValue = threshold == null ? null : threshold.getControlValue();
|
||||||
|
|
||||||
Statistics statistics = statisticsCalculator.calculate(data);
|
Statistics statistics = statisticsCalculator.calculate(data);
|
||||||
backfillModelInfo(statistics, model, context.getCategory());
|
backfillModelInfo(statistics, model);
|
||||||
|
|
||||||
TrendAnalysis trend = trendAnalyzer.analyze(data);
|
TrendAnalysis trend = trendAnalyzer.analyze(data);
|
||||||
// 越界检测按「相对初始值的累计变化量」判定,不能直接拿原始读数比控制值
|
// 越界检测按「相对初始值的累计变化量」判定,不能直接拿原始读数比控制值
|
||||||
@ -302,14 +304,19 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. 主指标(关键词匹配 + 辅助量过滤)
|
// 4. 主指标(关键词匹配 + 辅助量过滤)
|
||||||
String primaryIdentity = primaryMetricResolver.resolve(
|
// 多主指标:用户可显式勾选多个 is_primary=1 物模型(如水位 + 水温),全部收集;
|
||||||
|
// primaryIdentifier 保留为「主导主指标」,用于单值章节(概况/结论/安全等级/控制值/主图)。
|
||||||
|
List<String> primaryIdentifiers = primaryMetricResolver.resolveAll(
|
||||||
context, byIdentity, allowAuxiliaryAsPrimary(), primaryMetricKeywords());
|
context, byIdentity, allowAuxiliaryAsPrimary(), primaryMetricKeywords());
|
||||||
|
result.setPrimaryIdentifiers(primaryIdentifiers);
|
||||||
|
String primaryIdentity = primaryIdentifiers == null || primaryIdentifiers.isEmpty()
|
||||||
|
? null : primaryIdentifiers.get(0);
|
||||||
result.setPrimaryIdentifier(primaryIdentity);
|
result.setPrimaryIdentifier(primaryIdentity);
|
||||||
|
|
||||||
// 5. 组装测点数据(主指标排第一,辅助量作为影响量保留)
|
// 5. 组装测点数据(主指标排第一,辅助量作为影响量保留)
|
||||||
result.setMetricPoints(buildMetricPointsFiltered(result, context, dataByIdentity, statisticsDurationSeconds));
|
result.setMetricPoints(buildMetricPointsFiltered(result, context, dataByIdentity, statisticsDurationSeconds));
|
||||||
|
|
||||||
// 6. 生成结论
|
// 6. 生成结论(以主导主指标为准,保证概况/结论/安全等级口径一致)
|
||||||
buildConclusions(result, context, primaryIdentity);
|
buildConclusions(result, context, primaryIdentity);
|
||||||
|
|
||||||
// 7. 领域特定结论
|
// 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()) {
|
if (statistics.getModelName() == null || statistics.getModelName().isEmpty()) {
|
||||||
statistics.setModelName(model.getModelName());
|
statistics.setModelName(model.getModelName());
|
||||||
}
|
}
|
||||||
// 单位兜底:物模型优先,否则按类别默认
|
statistics.setUnit(ValueFormatter.unitOf(model));
|
||||||
String resolvedUnit = valueFormatter.resolveUnit(model.getUnit(), category);
|
|
||||||
statistics.setUnit(resolvedUnit);
|
|
||||||
statistics.setIdentifier(model.getIdentifier());
|
statistics.setIdentifier(model.getIdentifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -424,8 +433,9 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
Map<String, List<MetricData>> dataByIdentity,
|
Map<String, List<MetricData>> dataByIdentity,
|
||||||
long durationSeconds) {
|
long durationSeconds) {
|
||||||
List<MetricPointData> all = new ArrayList<>();
|
List<MetricPointData> all = new ArrayList<>();
|
||||||
String primary = result.getPrimaryIdentifier();
|
Set<String> primarySet = primaryIdentifierSet(result);
|
||||||
MetricPointData primaryPoint = null;
|
// 多主指标:每个主指标都排在前,均标记 primary=true(各出完整四列)
|
||||||
|
List<MetricPointData> primaryPoints = new ArrayList<>();
|
||||||
|
|
||||||
for (Map.Entry<String, Statistics> entry : result.getStatistics().entrySet()) {
|
for (Map.Entry<String, Statistics> entry : result.getStatistics().entrySet()) {
|
||||||
String identity = entry.getKey();
|
String identity = entry.getKey();
|
||||||
@ -437,32 +447,48 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean primary = primarySet.contains(identity);
|
||||||
MetricPointData point = buildMetricPoint(stats, result.getWarnings().get(identity), context,
|
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);
|
||||||
// 主监测指标标记(温度等辅助量为影响量,primary=false)
|
// 主监测指标标记(温度等辅助量为影响量,primary=false)
|
||||||
point.setPrimary(identity.equals(primary));
|
point.setPrimary(primary);
|
||||||
if (identity.equals(primary)) {
|
if (primary) {
|
||||||
primaryPoint = point;
|
primaryPoints.add(point);
|
||||||
} else {
|
} else {
|
||||||
all.add(point);
|
all.add(point);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主指标排第一
|
// 主指标排第一(按 primaryIdentifiers 顺序),辅助量随后
|
||||||
List<MetricPointData> filtered = new ArrayList<>();
|
List<MetricPointData> filtered = new ArrayList<>();
|
||||||
if (primaryPoint != null) {
|
List<String> primaryIdentifiers = result.getPrimaryIdentifiers();
|
||||||
filtered.add(primaryPoint);
|
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);
|
filtered.addAll(all);
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 主指标标识符集合(多主指标),空则退化为单值 primaryIdentifier */
|
||||||
* 判断是否为辅助指标
|
private Set<String> primaryIdentifierSet(AnalysisResult result) {
|
||||||
*/
|
Set<String> set = new LinkedHashSet<>();
|
||||||
protected boolean isAuxiliaryMetric(String modelName, String identifier) {
|
if (result.getPrimaryIdentifiers() != null && !result.getPrimaryIdentifiers().isEmpty()) {
|
||||||
return primaryMetricResolver.isAuxiliary(modelName + " " + identifier);
|
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},
|
* <p>物模型单位存放在 {@code iot_things_model.specs} JSON 的 {@code unit} 字段里,
|
||||||
* 因此这里必须回到物模型原始配置判断,否则水温、设备电压、角度等辅助量会被错标成主类别单位
|
* 实体 getter {@code ThingsModel.getUnit()} 对应列在表中并不存在(恒为 null),
|
||||||
* (如静力水准的 mm)。</p>
|
* 必须走 {@link ValueFormatter#unitOf(ThingsModel)}。</p>
|
||||||
*
|
*
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>物模型配置了单位:直接使用;</li>
|
* <li>物模型配置了单位:直接使用;</li>
|
||||||
@ -541,10 +567,9 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
private String resolveMetricUnit(Statistics stats, ReportContext context, boolean primaryMetric) {
|
private String resolveMetricUnit(Statistics stats, ReportContext context, boolean primaryMetric) {
|
||||||
ThingsModel model = findModelByIdentifier(context, stats.getIdentifier());
|
String modelUnit = ValueFormatter.unitOf(findModelByIdentifier(context, stats.getIdentifier()));
|
||||||
String modelUnit = model == null ? null : model.getUnit();
|
if (StringUtils.isNotEmpty(modelUnit)) {
|
||||||
if (modelUnit != null && !modelUnit.trim().isEmpty()) {
|
return modelUnit;
|
||||||
return modelUnit.trim();
|
|
||||||
}
|
}
|
||||||
if (!primaryMetric) {
|
if (!primaryMetric) {
|
||||||
return "";
|
return "";
|
||||||
@ -680,6 +705,10 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
.collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList()));
|
.collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList()));
|
||||||
Map<String, byte[]> charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity);
|
Map<String, byte[]> charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity);
|
||||||
result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts));
|
result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts));
|
||||||
|
|
||||||
|
// 多主指标:每个主指标各出一组三张曲线(时程/累计变化量/变化速率)
|
||||||
|
result.setMetricChartGroups(metricChartBuilder.buildGroups(
|
||||||
|
result.getStatistics(), result.getPrimaryIdentifiers(), byIdentity));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -26,7 +26,15 @@ public class AxialForceAnalyzer extends AbstractDeviceAnalyzer {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected String[] primaryMetricKeywords() {
|
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
|
@Override
|
||||||
|
|||||||
@ -81,6 +81,59 @@ public class MetricChartBuilder {
|
|||||||
return charts;
|
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) {
|
private List<MetricData> sortedSeries(List<MetricData> data) {
|
||||||
List<MetricData> sorted = new ArrayList<>();
|
List<MetricData> sorted = new ArrayList<>();
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import com.fastbee.report.chart.ChartGenerator;
|
|||||||
import com.fastbee.report.data.MetricData;
|
import com.fastbee.report.data.MetricData;
|
||||||
import com.fastbee.report.model.AnalysisResult;
|
import com.fastbee.report.model.AnalysisResult;
|
||||||
import com.fastbee.report.model.Conclusion;
|
import com.fastbee.report.model.Conclusion;
|
||||||
|
import com.fastbee.report.model.MetricChartGroup;
|
||||||
import com.fastbee.report.model.MetricPointData;
|
import com.fastbee.report.model.MetricPointData;
|
||||||
import com.fastbee.report.model.Statistics;
|
import com.fastbee.report.model.Statistics;
|
||||||
import com.fastbee.report.model.WarningAnalysis;
|
import com.fastbee.report.model.WarningAnalysis;
|
||||||
@ -104,6 +105,8 @@ public class MultiDeviceAnalysisMerger {
|
|||||||
|
|
||||||
AnalysisResult merged = new AnalysisResult();
|
AnalysisResult merged = new AnalysisResult();
|
||||||
merged.setPrimaryIdentifier(base.getPrimaryIdentifier());
|
merged.setPrimaryIdentifier(base.getPrimaryIdentifier());
|
||||||
|
// 多主指标:沿用最不利测点的主指标集合(同一产品各测点物模型一致,主指标集合也应一致)
|
||||||
|
merged.setPrimaryIdentifiers(base.getPrimaryIdentifiers());
|
||||||
merged.setSummary(base.getSummary());
|
merged.setSummary(base.getSummary());
|
||||||
merged.setProfessionalAdvice(base.getProfessionalAdvice());
|
merged.setProfessionalAdvice(base.getProfessionalAdvice());
|
||||||
merged.setSafetyLevel(base.getSafetyLevel());
|
merged.setSafetyLevel(base.getSafetyLevel());
|
||||||
@ -121,9 +124,79 @@ public class MultiDeviceAnalysisMerger {
|
|||||||
merged.setConclusions(buildConclusions(base, valid));
|
merged.setConclusions(buildConclusions(base, valid));
|
||||||
merged.getCharts().putAll(base.getCharts());
|
merged.getCharts().putAll(base.getCharts());
|
||||||
merged.getCharts().putAll(buildMultiPointCharts(valid, base.getPrimaryIdentifier()));
|
merged.getCharts().putAll(buildMultiPointCharts(valid, base.getPrimaryIdentifier()));
|
||||||
|
// 多主指标图表组:每个主指标一组(多测点时各测点一条曲线)
|
||||||
|
merged.setMetricChartGroups(buildMultiPointGroups(valid, base));
|
||||||
return appendSkipped(merged, skippedPoints, totalPoints);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把"统计期内无数据、未纳入分析"的测点写入结论。
|
* 把"统计期内无数据、未纳入分析"的测点写入结论。
|
||||||
*
|
*
|
||||||
|
|||||||
@ -54,6 +54,9 @@ public class ReportAssembler {
|
|||||||
primary = analysis.getStatistics().values().stream().findFirst().orElse(null);
|
primary = analysis.getStatistics().values().stream().findFirst().orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 多主指标:收集全部主指标名称(水位、水温…),供头部「监测指标」字段并列展示
|
||||||
|
java.util.List<String> metricNames = collectMetricNames(analysis, primary);
|
||||||
|
|
||||||
// 添加监测分类描述
|
// 添加监测分类描述
|
||||||
String categoryDesc = context.getCategory().getDesc() + "监测分析报告";
|
String categoryDesc = context.getCategory().getDesc() + "监测分析报告";
|
||||||
|
|
||||||
@ -71,8 +74,31 @@ public class ReportAssembler {
|
|||||||
.category(context.getCategory())
|
.category(context.getCategory())
|
||||||
.categoryDesc(categoryDesc) // 添加分类描述
|
.categoryDesc(categoryDesc) // 添加分类描述
|
||||||
.metricName(primary == null ? null : primary.getModelName())
|
.metricName(primary == null ? null : primary.getModelName())
|
||||||
|
.metricNames(metricNames)
|
||||||
.metricUnit(primary == null ? null : primary.getUnit())
|
.metricUnit(primary == null ? null : primary.getUnit())
|
||||||
.analysis(analysis)
|
.analysis(analysis)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集全部主监测指标名称(有序)
|
||||||
|
*
|
||||||
|
* <p>多主指标(primaryIdentifiers 非空且 >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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import lombok.Data;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 报告上下文(设备/产品/物模型/时序数据/阈值一次性装配)
|
* 报告上下文(设备/产品/物模型/时序数据/阈值一次性装配)
|
||||||
@ -49,6 +50,18 @@ public class ReportContext {
|
|||||||
/** 阈值(key = 物模型标识符) */
|
/** 阈值(key = 物模型标识符) */
|
||||||
private Map<String, ThingsThreshold> thresholds;
|
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;
|
private String reportNo;
|
||||||
|
|
||||||
/** 创建人用户名(从主线程传递,避免异步线程无法访问 SecurityContext) */
|
/** 创建人用户名(从主线程传递,避免异步线程无法访问 SecurityContext) */
|
||||||
@ -56,4 +69,13 @@ public class ReportContext {
|
|||||||
|
|
||||||
/** 租户ID(从主线程传递) */
|
/** 租户ID(从主线程传递) */
|
||||||
private Long tenantId;
|
private Long tenantId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 该物模型是否被用户显式指定为主监测指标
|
||||||
|
*/
|
||||||
|
public boolean isPrimaryMetric(String identifier) {
|
||||||
|
return identifier != null
|
||||||
|
&& explicitPrimaryIdentifiers != null
|
||||||
|
&& explicitPrimaryIdentifiers.contains(identifier);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,9 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@ -91,6 +93,8 @@ public class ReportContextBuilder {
|
|||||||
modelQuery.setType(1);
|
modelQuery.setType(1);
|
||||||
modelQuery.setIsMonitor(1);
|
modelQuery.setIsMonitor(1);
|
||||||
List<ThingsModel> models = thingsModelService.selectThingsModelList(modelQuery);
|
List<ThingsModel> models = thingsModelService.selectThingsModelList(modelQuery);
|
||||||
|
// 同一产品下各设备可能各自定义设备级物模型(device_id>0),先收窄到本设备的
|
||||||
|
models = filterByDevice(models, deviceId);
|
||||||
// 前端勾选的物模型:只分析所选物理量;未勾选(空)表示分析产品下全部监测物模型
|
// 前端勾选的物模型:只分析所选物理量;未勾选(空)表示分析产品下全部监测物模型
|
||||||
models = filterByIdentifiers(models, request.getIdentifiers(), 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();
|
ReportContext context = new ReportContext();
|
||||||
context.setRequest(request);
|
context.setRequest(request);
|
||||||
context.setTimeWindow(window);
|
context.setTimeWindow(window);
|
||||||
@ -129,6 +142,7 @@ public class ReportContextBuilder {
|
|||||||
context.setMetricDataList(metricDataList);
|
context.setMetricDataList(metricDataList);
|
||||||
context.setBaselineMetricDataList(baselineMetricDataList);
|
context.setBaselineMetricDataList(baselineMetricDataList);
|
||||||
context.setThresholds(thresholds);
|
context.setThresholds(thresholds);
|
||||||
|
context.setExplicitPrimaryIdentifiers(explicitPrimaryIdentifiers);
|
||||||
context.setReportNo(reportNo);
|
context.setReportNo(reportNo);
|
||||||
|
|
||||||
// 从主线程获取用户信息,传递到异步线程
|
// 从主线程获取用户信息,传递到异步线程
|
||||||
@ -151,6 +165,54 @@ public class ReportContextBuilder {
|
|||||||
return context;
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按请求指定的物模型标识符过滤监测物模型
|
* 按请求指定的物模型标识符过滤监测物模型
|
||||||
*
|
*
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import com.fastbee.iot.domain.DeviceLog;
|
|||||||
import com.fastbee.iot.domain.ThingsModel;
|
import com.fastbee.iot.domain.ThingsModel;
|
||||||
import com.fastbee.iot.model.HistoryModel;
|
import com.fastbee.iot.model.HistoryModel;
|
||||||
import com.fastbee.iot.service.IDeviceLogService;
|
import com.fastbee.iot.service.IDeviceLogService;
|
||||||
|
import com.fastbee.report.analysis.ValueFormatter;
|
||||||
import com.fastbee.report.period.TimeWindow;
|
import com.fastbee.report.period.TimeWindow;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@ -72,7 +73,8 @@ public class ReportDataProvider {
|
|||||||
ThingsModel model = modelMap.get(history.getIdentity());
|
ThingsModel model = modelMap.get(history.getIdentity());
|
||||||
if (model != null) {
|
if (model != null) {
|
||||||
data.setModelName(model.getModelName());
|
data.setModelName(model.getModelName());
|
||||||
data.setUnit(model.getUnit());
|
// 单位存在 specs JSON 中,实体 getter 取不到(见 ValueFormatter#unitOf)
|
||||||
|
data.setUnit(ValueFormatter.unitOf(model));
|
||||||
}
|
}
|
||||||
result.add(data);
|
result.add(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,9 @@ public interface ThingsThresholdMapper extends BaseMapper<ThingsThreshold> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 按优先级查询阈值:device_id > product_id > 全局默认(product_id=0)
|
* 按优先级查询阈值:device_id > product_id > 全局默认(product_id=0)
|
||||||
|
*
|
||||||
|
* <p>逐物模型(标识符)解析,返回的整行里同时带出 {@code is_primary}(主监测指标标记)。
|
||||||
|
* 主指标的级别口径与阈值数值完全一致:设备级有行即以设备行为准,否则继承产品级。</p>
|
||||||
*/
|
*/
|
||||||
ThingsThreshold selectByPriority(@Param("deviceId") Long deviceId,
|
ThingsThreshold selectByPriority(@Param("deviceId") Long deviceId,
|
||||||
@Param("productId") Long productId,
|
@Param("productId") Long productId,
|
||||||
|
|||||||
@ -17,9 +17,26 @@ import java.util.Map;
|
|||||||
@Data
|
@Data
|
||||||
public class AnalysisResult {
|
public class AnalysisResult {
|
||||||
|
|
||||||
/** 主监测指标标识符(统一由 Analyzer 解析,ReportAssembler/WordRenderer 共用) */
|
/**
|
||||||
|
* 主监测指标标识符(统一由 Analyzer 解析,ReportAssembler/WordRenderer 共用)
|
||||||
|
*
|
||||||
|
* <p><b>单值语义:</b>在多个主指标中代表「主导主指标」——用于单值章节
|
||||||
|
* (项目概况 / 结论 / 安全等级 / 控制值 / 时程曲线主图)。取值口径:
|
||||||
|
* 预警等级最高者优先,同等级取变化量绝对值最大者(与多测点「最不利测点」一致)。</p>
|
||||||
|
*/
|
||||||
private String primaryIdentifier;
|
private String primaryIdentifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全部主监测指标标识符(有序,来自 PrimaryMetricResolver 的多主指标解析)
|
||||||
|
*
|
||||||
|
* <p>用户可显式勾选多个 {@code is_primary=1} 物模型(如静力水准仪的「水位」与「水温」),
|
||||||
|
* 它们应并列作为报告的监测指标,而非只取一个。头部「监测指标」字段与测点表
|
||||||
|
* 据此渲染多个主指标(各出完整四列),而单值章节仍以 {@link #primaryIdentifier} 为主。</p>
|
||||||
|
*
|
||||||
|
* <p>为空列表/null 时视为未解析,兼容旧逻辑回退到 {@link #primaryIdentifier}。</p>
|
||||||
|
*/
|
||||||
|
private List<String> primaryIdentifiers;
|
||||||
|
|
||||||
/** 项目概况总结(开头段落,概括关键数据) */
|
/** 项目概况总结(开头段落,概括关键数据) */
|
||||||
private String summary;
|
private String summary;
|
||||||
|
|
||||||
@ -58,4 +75,13 @@ public class AnalysisResult {
|
|||||||
|
|
||||||
/** 图表数据:key = 模板占位符(如 crackTrend),value = PNG byte[] */
|
/** 图表数据:key = 模板占位符(如 crackTrend),value = PNG byte[] */
|
||||||
private Map<String, byte[]> charts = new LinkedHashMap<>();
|
private Map<String, byte[]> charts = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每个主监测指标的一组图表(有序,与 primaryIdentifiers 对齐)
|
||||||
|
*
|
||||||
|
* <p>多主指标时每个主指标各出一组三张曲线(时程/累计变化量/变化速率),
|
||||||
|
* 由 {@code MetricChartBuilder} 生成、{@code WordRenderer} 用图片列表循环渲染。
|
||||||
|
* 单主指标时列表仅一个元素,行为与旧版一致。</p>
|
||||||
|
*/
|
||||||
|
private List<MetricChartGroup> metricChartGroups = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -47,6 +47,14 @@ public class ReportModel {
|
|||||||
/** 主监测指标(裂缝宽度)名称 */
|
/** 主监测指标(裂缝宽度)名称 */
|
||||||
private String metricName;
|
private String metricName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全部主监测指标名称(有序,与 AnalysisResult.primaryIdentifiers 对应)
|
||||||
|
*
|
||||||
|
* <p>单主指标时与 {@link #metricName} 等价(单元素列表);多主指标时包含所有主指标名。
|
||||||
|
* 头部「监测指标」字段据此渲染多个名称。为空时回退 {@link #metricName}。</p>
|
||||||
|
*/
|
||||||
|
private java.util.List<String> metricNames;
|
||||||
|
|
||||||
/** 主监测指标单位 */
|
/** 主监测指标单位 */
|
||||||
private String metricUnit;
|
private String metricUnit;
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import com.fastbee.report.analysis.ValueFormatter;
|
|||||||
import com.fastbee.report.exception.ReportException;
|
import com.fastbee.report.exception.ReportException;
|
||||||
import com.fastbee.report.model.AnalysisResult;
|
import com.fastbee.report.model.AnalysisResult;
|
||||||
import com.fastbee.report.model.Conclusion;
|
import com.fastbee.report.model.Conclusion;
|
||||||
|
import com.fastbee.report.model.MetricChartGroup;
|
||||||
import com.fastbee.report.model.MetricPointData;
|
import com.fastbee.report.model.MetricPointData;
|
||||||
import com.fastbee.report.model.QualityAnalysis;
|
import com.fastbee.report.model.QualityAnalysis;
|
||||||
import com.fastbee.report.model.ReportModel;
|
import com.fastbee.report.model.ReportModel;
|
||||||
@ -50,6 +51,7 @@ import java.util.HashMap;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Word 渲染器(poi-tl 模板渲染 + JFreeChart PNG 嵌入,禁止查库)
|
* Word 渲染器(poi-tl 模板渲染 + JFreeChart PNG 嵌入,禁止查库)
|
||||||
@ -250,7 +252,7 @@ public class WordRenderer implements ReportRenderer {
|
|||||||
device.put("name", model.getDeviceName());
|
device.put("name", model.getDeviceName());
|
||||||
device.put("serialNumber", model.getSerialNumber());
|
device.put("serialNumber", model.getSerialNumber());
|
||||||
device.put("productName", model.getProductName());
|
device.put("productName", model.getProductName());
|
||||||
device.put("metricName", model.getMetricName());
|
device.put("metricName", metricNameText(model));
|
||||||
device.put("metricUnit", model.getMetricUnit() == null ? "" : model.getMetricUnit());
|
device.put("metricUnit", model.getMetricUnit() == null ? "" : model.getMetricUnit());
|
||||||
|
|
||||||
Map<String, Object> statistics = new HashMap<>();
|
Map<String, Object> statistics = new HashMap<>();
|
||||||
@ -370,6 +372,9 @@ public class WordRenderer implements ReportRenderer {
|
|||||||
charts.put(entry.getKey(), picture);
|
charts.put(entry.getKey(), picture);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 多主指标图表组:每个主指标一组三张曲线(时程/累计变化量/变化速率),模板区块循环渲染
|
||||||
|
List<Map<String, Object>> metricCharts = buildMetricCharts(analysis);
|
||||||
|
|
||||||
List<Map<String, Object>> conclusions = new ArrayList<>();
|
List<Map<String, Object>> conclusions = new ArrayList<>();
|
||||||
for (Conclusion conclusion : analysis.getConclusions()) {
|
for (Conclusion conclusion : analysis.getConclusions()) {
|
||||||
Map<String, Object> item = new HashMap<>();
|
Map<String, Object> item = new HashMap<>();
|
||||||
@ -382,10 +387,60 @@ public class WordRenderer implements ReportRenderer {
|
|||||||
data.put("device", device);
|
data.put("device", device);
|
||||||
data.put("analysis", analysisMap);
|
data.put("analysis", analysisMap);
|
||||||
data.put("charts", charts);
|
data.put("charts", charts);
|
||||||
|
data.put("metricCharts", metricCharts);
|
||||||
data.put("conclusions", conclusions);
|
data.put("conclusions", conclusions);
|
||||||
return data;
|
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,兜底第一个
|
* 解析主监测指标统计:优先 primaryIdentifier,兜底第一个
|
||||||
*/
|
*/
|
||||||
@ -399,6 +454,26 @@ public class WordRenderer implements ReportRenderer {
|
|||||||
return analysis.getStatistics().values().stream().findFirst().orElse(null);
|
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}})
|
* 构建动态列测点表(模板占位 {{metricTable}})
|
||||||
*
|
*
|
||||||
@ -432,9 +507,19 @@ public class WordRenderer implements ReportRenderer {
|
|||||||
|
|
||||||
// 2. 列顺序:主指标优先,其余按首次出现顺序(全测点统一,便于横向对比)
|
// 2. 列顺序:主指标优先,其余按首次出现顺序(全测点统一,便于横向对比)
|
||||||
List<String> orderedIdentifiers = new ArrayList<>();
|
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();
|
String primaryIdentifier = analysis.getPrimaryIdentifier();
|
||||||
if (primaryIdentifier != null) {
|
if (primaryIdentifier != null && !orderedIdentifiers.contains(primaryIdentifier)) {
|
||||||
orderedIdentifiers.add(primaryIdentifier);
|
orderedIdentifiers.add(primaryIdentifier);
|
||||||
|
primarySet.add(primaryIdentifier);
|
||||||
}
|
}
|
||||||
for (MetricPointData point : points) {
|
for (MetricPointData point : points) {
|
||||||
if (point.getIdentifier() != null && !orderedIdentifiers.contains(point.getIdentifier())) {
|
if (point.getIdentifier() != null && !orderedIdentifiers.contains(point.getIdentifier())) {
|
||||||
@ -456,7 +541,7 @@ public class WordRenderer implements ReportRenderer {
|
|||||||
MetricPointData sample = findSample(points, identifier);
|
MetricPointData sample = findSample(points, identifier);
|
||||||
String name = sample == null ? identifier : nvl(sample.getModelName());
|
String name = sample == null ? identifier : nvl(sample.getModelName());
|
||||||
String unit = sample == null ? "" : nvl(sample.getCurrentUnit());
|
String unit = sample == null ? "" : nvl(sample.getCurrentUnit());
|
||||||
boolean primary = identifier.equals(primaryIdentifier);
|
boolean primary = primarySet.contains(identifier);
|
||||||
if (primary) {
|
if (primary) {
|
||||||
header.add(name + "·初始值" + unitSuffix(unit));
|
header.add(name + "·初始值" + unitSuffix(unit));
|
||||||
header.add(name + "·本次值" + unitSuffix(unit));
|
header.add(name + "·本次值" + unitSuffix(unit));
|
||||||
@ -494,14 +579,14 @@ public class WordRenderer implements ReportRenderer {
|
|||||||
MetricPointData point = byIdentifier.get(identifier);
|
MetricPointData point = byIdentifier.get(identifier);
|
||||||
if (point == null) {
|
if (point == null) {
|
||||||
row.add("-");
|
row.add("-");
|
||||||
if (identifier.equals(primaryIdentifier)) {
|
if (primarySet.contains(identifier)) {
|
||||||
row.add("-");
|
row.add("-");
|
||||||
row.add("-");
|
row.add("-");
|
||||||
row.add("-");
|
row.add("-");
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (identifier.equals(primaryIdentifier)) {
|
if (primarySet.contains(identifier)) {
|
||||||
row.add(strForTable(point.getInitialValue(), category));
|
row.add(strForTable(point.getInitialValue(), category));
|
||||||
row.add(strForTable(point.getCurrentValue(), category));
|
row.add(strForTable(point.getCurrentValue(), category));
|
||||||
row.add(strForTable(point.getChangeValue(), category));
|
row.add(strForTable(point.getChangeValue(), category));
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
package com.fastbee.report.threshold;
|
package com.fastbee.report.threshold;
|
||||||
|
|
||||||
import com.fastbee.common.utils.StringUtils;
|
|
||||||
import com.fastbee.report.mapper.ThingsThresholdMapper;
|
import com.fastbee.report.mapper.ThingsThresholdMapper;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
|||||||
@ -50,6 +50,19 @@ public class ThingsThreshold {
|
|||||||
|
|
||||||
private String unit;
|
private String unit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否主监测指标(1=是,0/NULL=否,由关键词推断)
|
||||||
|
*
|
||||||
|
* <p>报告里的「监测指标」默认由 {@code PrimaryMetricResolver} 按关键词推断,
|
||||||
|
* 命名不规范时会误判(现场曾把 GPS「经度」选成轴力计的监测指标)。
|
||||||
|
* 这里提供显式指定的能力:被标记的物模型,优先级高于任何关键词推断。</p>
|
||||||
|
*
|
||||||
|
* <p>级别口径与阈值数值**完全一致**({@code selectByPriority} 的 device > product,
|
||||||
|
* 逐标识符解析):某个物模型设备级配了就以设备行为准 —— 设备行没勾、产品行勾了也不作数;
|
||||||
|
* 设备级没配才继承产品行的勾选。</p>
|
||||||
|
*/
|
||||||
|
private Boolean isPrimary;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private String createBy;
|
private String createBy;
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,9 @@ public interface ThingsThresholdProvider {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询指定设备 + 物模型标识符的结构化阈值(device > product > 全局)
|
* 查询指定设备 + 物模型标识符的结构化阈值(device > product > 全局)
|
||||||
|
*
|
||||||
|
* <p>返回行的 {@code isPrimary} 即"该物模型被用户显式指定为主监测指标"。
|
||||||
|
* 级别口径与阈值数值同一套:设备级有行即以设备行为准,设备没配就继承产品级的勾选。</p>
|
||||||
*/
|
*/
|
||||||
ThingsThreshold find(Long deviceId, Long productId, String categoryCode, String metricIdentifier);
|
ThingsThreshold find(Long deviceId, Long productId, String categoryCode, String metricIdentifier);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,11 +4,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
<mapper namespace="com.fastbee.report.mapper.ThingsThresholdMapper">
|
<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="selectByPriority" resultType="com.fastbee.report.threshold.ThingsThreshold">
|
||||||
select id, product_id, device_id, monitor_category_code, metric_identifier,
|
select id, product_id, device_id, monitor_category_code, metric_identifier,
|
||||||
warning_value, alarm_value, control_value,
|
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
|
create_by, create_time, update_by, update_time
|
||||||
from iot_things_threshold
|
from iot_things_threshold
|
||||||
<where>
|
<where>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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 |
@ -87,6 +87,7 @@ CREATE TABLE `iot_things_threshold` (
|
|||||||
`alarm_rate` DECIMAL(18, 6) DEFAULT NULL COMMENT '变化速率报警值(单位/天)' ,
|
`alarm_rate` DECIMAL(18, 6) DEFAULT NULL COMMENT '变化速率报警值(单位/天)' ,
|
||||||
`control_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 '单位',
|
`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_by` varchar(64) COLLATE utf8_unicode_ci DEFAULT '' COMMENT '创建者',
|
||||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||||
`update_by` varchar(64) COLLATE utf8_unicode_ci DEFAULT '' 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
|
KEY `idx_threshold_product` (`product_id`, `metric_identifier`) USING BTREE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='报告结构化阈值';
|
) 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 表)
|
-- 2.2 Word 模板(不再建 report_template 表)
|
||||||
-- 模板作为代码资产放在 fastbee-report/src/main/resources/templates/
|
-- 模板作为代码资产放在 fastbee-report/src/main/resources/templates/
|
||||||
|
|||||||
@ -25,6 +25,23 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="单位" align="center" width="80" prop="unit" />
|
<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">
|
<el-table-column label="预警值" align="center" width="140">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-input v-model="scope.row.warningValue" size="mini" type="number" placeholder="未设置" />
|
<el-input v-model="scope.row.warningValue" size="mini" type="number" placeholder="未设置" />
|
||||||
@ -61,6 +78,20 @@
|
|||||||
<span v-else style="color: #c0c4cc">未配置</span>
|
<span v-else style="color: #c0c4cc">未配置</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -72,6 +103,8 @@ import { listThreshold, addThreshold, updateThreshold, delThreshold } from '@/ap
|
|||||||
|
|
||||||
/** 仅数值型物模型可配置阈值 */
|
/** 仅数值型物模型可配置阈值 */
|
||||||
const NUMERIC_TYPES = ['integer', 'decimal'];
|
const NUMERIC_TYPES = ['integer', 'decimal'];
|
||||||
|
/** 行内可清空的阈值字段(主监测指标开关单独处理) */
|
||||||
|
const VALUE_FIELDS = ['warningValue', 'alarmValue', 'controlValue', 'warningRate', 'alarmRate', 'controlRate'];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ThresholdConfig',
|
name: 'ThresholdConfig',
|
||||||
@ -107,8 +140,8 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
tipText() {
|
tipText() {
|
||||||
return this.scopeType === 'device'
|
return this.scopeType === 'device'
|
||||||
? '设备阈值优先级高于产品阈值;留空则继承所属产品的阈值'
|
? '设备阈值优先级高于产品阈值;留空则继承所属产品的阈值。主监测指标:本设备未配置该物模型时继承产品级的勾选,一旦本设备配置了则以本设备为准'
|
||||||
: '产品阈值对该产品下所有设备生效,可被单个设备的设备阈值覆盖';
|
: '产品阈值对该产品下所有设备生效,可被单个设备的设备阈值覆盖。主监测指标:勾选后该产品下所有设备的报告都以此物模型为准(设备侧可覆盖)';
|
||||||
},
|
},
|
||||||
/** 保存阈值时落库的 deviceId:产品级为 0 */
|
/** 保存阈值时落库的 deviceId:产品级为 0 */
|
||||||
targetDeviceId() {
|
targetDeviceId() {
|
||||||
@ -187,6 +220,7 @@ export default {
|
|||||||
datatype: m.datatype,
|
datatype: m.datatype,
|
||||||
unit: this.parseUnit(m.specs),
|
unit: this.parseUnit(m.specs),
|
||||||
id: undefined,
|
id: undefined,
|
||||||
|
isPrimary: false,
|
||||||
warningValue: undefined,
|
warningValue: undefined,
|
||||||
alarmValue: undefined,
|
alarmValue: undefined,
|
||||||
controlValue: undefined,
|
controlValue: undefined,
|
||||||
@ -215,6 +249,7 @@ export default {
|
|||||||
this.rows.forEach((row) => {
|
this.rows.forEach((row) => {
|
||||||
const t = ownMap[row.identifier];
|
const t = ownMap[row.identifier];
|
||||||
row.id = t ? t.id : undefined;
|
row.id = t ? t.id : undefined;
|
||||||
|
row.isPrimary = !!(t && t.isPrimary);
|
||||||
row.warningValue = t && t.warningValue !== null ? t.warningValue : undefined;
|
row.warningValue = t && t.warningValue !== null ? t.warningValue : undefined;
|
||||||
row.alarmValue = t && t.alarmValue !== null ? t.alarmValue : undefined;
|
row.alarmValue = t && t.alarmValue !== null ? t.alarmValue : undefined;
|
||||||
row.controlValue = t && t.controlValue !== null ? t.controlValue : undefined;
|
row.controlValue = t && t.controlValue !== null ? t.controlValue : undefined;
|
||||||
@ -232,24 +267,70 @@ export default {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
/** 产品级阈值列展示:阈值值 + 产品级的主指标勾选 */
|
||||||
formatThreshold(t) {
|
formatThreshold(t) {
|
||||||
if (!t) return '';
|
if (!t) return '';
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (t.warningValue !== null && t.warningValue !== undefined) parts.push('预警 ' + t.warningValue);
|
if (t.warningValue !== null && t.warningValue !== undefined) parts.push('预警 ' + t.warningValue);
|
||||||
if (t.alarmValue !== null && t.alarmValue !== undefined) parts.push('报警 ' + t.alarmValue);
|
if (t.alarmValue !== null && t.alarmValue !== undefined) parts.push('报警 ' + t.alarmValue);
|
||||||
if (t.controlValue !== null && t.controlValue !== undefined) parts.push('控制 ' + t.controlValue);
|
if (t.controlValue !== null && t.controlValue !== undefined) parts.push('控制 ' + t.controlValue);
|
||||||
|
if (t.isPrimary) parts.push('产品级主指标');
|
||||||
return parts.length ? parts.join(' / ') : '未配置';
|
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) {
|
hasAnyValue(row) {
|
||||||
return [
|
return (
|
||||||
row.warningValue,
|
row.isPrimary === true ||
|
||||||
row.alarmValue,
|
VALUE_FIELDS.some((k) => {
|
||||||
row.controlValue,
|
const v = row[k];
|
||||||
row.warningRate,
|
return v !== undefined && v !== null && v !== '';
|
||||||
row.alarmRate,
|
})
|
||||||
row.controlRate,
|
);
|
||||||
].some((v) => 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() {
|
save() {
|
||||||
@ -267,6 +348,7 @@ export default {
|
|||||||
monitorCategoryCode: this.monitorCategoryCode,
|
monitorCategoryCode: this.monitorCategoryCode,
|
||||||
metricIdentifier: row.identifier,
|
metricIdentifier: row.identifier,
|
||||||
unit: row.unit,
|
unit: row.unit,
|
||||||
|
isPrimary: row.isPrimary === true,
|
||||||
warningValue: row.warningValue === '' ? undefined : row.warningValue,
|
warningValue: row.warningValue === '' ? undefined : row.warningValue,
|
||||||
alarmValue: row.alarmValue === '' ? undefined : row.alarmValue,
|
alarmValue: row.alarmValue === '' ? undefined : row.alarmValue,
|
||||||
controlValue: row.controlValue === '' ? undefined : row.controlValue,
|
controlValue: row.controlValue === '' ? undefined : row.controlValue,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user