reportEngine:完善报告导出

This commit is contained in:
zhugongping 2026-09-22 09:54:51 +08:00
parent 86b85f2327
commit ae37f21d6d
24 changed files with 428 additions and 40 deletions

View File

@ -7,13 +7,13 @@ networks:
services:
minio:
image: minio/minio:latest
image: quay.io/minio/minio:latest
container_name: minio
restart: always
# privileged: true 它几乎拥有与宿主机 root 相同的所有权限;MinIO 是纯用户空间的对象存储服务;无需用到特权模式,故而安全性与最小权限原则
networks:
network:
ipv4_address: 177.7.0.6
ipv4_address: 176.6.0.6
ports:
- "9000:9000"
- "9001:9001"

View File

@ -4,7 +4,7 @@
>
> **本 Skill 目标**:基于已落库的 IoT 物模型监测数据,为单个监测设备按「设备 + 时间范围」生成专业分析报告(Word `.docx`),支持历史报表下载,**保留期 1 个月**。
>
> **前端入口(2 处)**:① 大屏分析模式;② 数据中心分析页。两处均通过「选设备 + 选时间」即可生成并导出报表;另有历史报表列表可下载。
> **前端入口(2 处)**:① 大屏分析模式;② 数据中心分析页。两处均通过「选设备 + 选时间」即可生成并导出分析报告;另有历史报表列表可下载。
>
> **存储决策**:**已确认未购买云 OSS 服务 → 统一使用自建 MinIO**(S3 协议兼容,复用现有 `fastbee-oss` 插件,不改上传链路)。
>
@ -43,7 +43,7 @@
4. 结构化报告阈值:告警阈值藏在 LiteFlow `iot_scene` 脚本里,报告不可直接读 → 新增 `iot_things_threshold`。
5. Java 端图表(JFreeChart)与 Word 模板引擎(poi-tl)。
6. MinIO 默认存储配置。
7. 前端两处「生成并导出报表」按钮 + 历史报表页(列表/下载/过期)。
7. 前端两处「生成并导出分析报告」按钮 + 历史报表页(列表/下载/过期)。
## 1.4 前端现状
@ -262,7 +262,7 @@ UploadResult r = OssFactory.instance(redis)
## 5.1 入口一:大屏分析模式(`bigScreen/home.vue`)
- 在「分析条件」卡片“查询数据和曲线”旁新增按钮 **「生成并导出报表」**。
- 在「分析条件」卡片“查询数据和曲线”旁新增按钮 **「生成并导出分析报告」**。
- 参数直接取已有 `analysisQuery.deviceId` + `analysisQuery.timeRange`(`[startTime, endTime]`)。
- 交互:点击 → `POST /iot/report/generate`(loading 防重复)→ 成功拿 `reportNo/fileUrl` 直接触发下载,并提示可在历史报表查看。
- 未选设备或时间时按钮禁用。
@ -288,7 +288,7 @@ async handleGenerateReport() {
## 5.2 入口二:数据中心(`dataCenter/analysis.vue`)
- 在筛选栏“查询/重置”旁新增 **「生成并导出报表」**。
- 在筛选栏“查询/重置”旁新增 **「生成并导出分析报告」**。
- 参数取已有 `queryParams.deviceId` + `queryParams.dayDaterange`。
- 交互与 5.1 一致,复用同一 API 封装。
@ -402,7 +402,7 @@ CREATE TABLE report_record (
6. **Provider**:`ReportDataProvider`(委托 `DeviceLogMapper`)、`ThingsThresholdProvider`。
7. **渲染**:`ChartGenerator`(JFreeChart)+ `WordRenderer`(poi-tl)+ 模板 `DEVICE_CRACK_METER_V1.docx`。
8. **落库与下载**:`report_record` + `expire_time`;MinIO 生命周期规则;下载接口(校验过期)。
9. **前端**:大屏分析模式 + 数据中心加「生成并导出报表」;历史报表页 + 下载。
9. **前端**:大屏分析模式 + 数据中心加「生成并导出分析报告」;历史报表页 + 下载。
10. **闭环验证**:真实设备 + 时间范围 → 生成 → 下载 → 列表可见 → 过期处理验证。
---
@ -420,7 +420,7 @@ CREATE TABLE report_record (
[ ] report_record 落库,expire_time = +30 天
[ ] 列表接口只返回未过期记录
[ ] 下载接口可下载;过期返回明确提示
[ ] 大屏分析模式与数据中心均可「生成并导出报表」
[ ] 大屏分析模式与数据中心均可「生成并导出分析报告」
[ ] 历史报表页面可查看/下载
[ ] 设备 304259363251 完整闭环验证通过
```

View File

@ -47,7 +47,7 @@ public class MybatisPlusMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
log.debug("mybatisPlus插入填充--------------");
//log.debug("mybatisPlus插入填充--------------");
Date date = new Date();
fillValIfNullByName("createTime", date, metaObject, false);
@ -59,7 +59,7 @@ public class MybatisPlusMetaObjectHandler implements MetaObjectHandler {
@Override
public void updateFill(MetaObject metaObject) {
log.debug("mybatisPlus更新填充--------------");
//log.debug("mybatisPlus更新填充--------------");
fillValIfNullByName("updateTime", new Date(), metaObject, true);
fillValIfNullByName("updateBy", getUserName(), metaObject, true);
}

View File

@ -285,6 +285,17 @@ public class DeviceController extends BaseController {
return deviceService.updateDevice(device);
}
/**
* 批量更新设备基准点(仅写入 baseline_device_id)
*/
@PreAuthorize("@ss.hasPermi('iot:device:edit')")
@Log(title = "批量更新设备基准点", businessType = BusinessType.UPDATE)
@PostMapping("/baseline/batch")
@ApiOperation("批量更新设备基准点")
public AjaxResult updateBaselineBatch(@RequestBody List<Device> devices) {
return AjaxResult.success(deviceService.updateDeviceBaselineBatch(devices));
}
/**
* 重置设备状态
*/

View File

@ -79,25 +79,31 @@ public class ThingsThresholdController extends BaseController {
}
/**
* 新增阈值
* 批量新增阈值
*/
@PreAuthorize("@ss.hasPermi('iot:device:list')")
@Log(title = "监测阈值配置", businessType = BusinessType.INSERT)
@PostMapping
@ApiOperation("新增阈值")
public AjaxResult add(@RequestBody ThingsThreshold threshold) {
return toAjax(thingsThresholdService.insertThreshold(threshold));
@ApiOperation("批量新增阈值")
public AjaxResult add(@RequestBody List<ThingsThreshold> thresholds) {
if (thresholds == null || thresholds.isEmpty()) {
return AjaxResult.error("新增列表不能为空");
}
return toAjax(thingsThresholdService.insertThresholdBatch(thresholds));
}
/**
* 修改阈值
* 批量修改阈值
*/
@PreAuthorize("@ss.hasPermi('iot:device:list')")
@Log(title = "监测阈值配置", businessType = BusinessType.UPDATE)
@PutMapping
@ApiOperation("修改阈值")
public AjaxResult edit(@RequestBody ThingsThreshold threshold) {
return toAjax(thingsThresholdService.updateThreshold(threshold));
@ApiOperation("批量修改阈值")
public AjaxResult edit(@RequestBody List<ThingsThreshold> thresholds) {
if (thresholds == null || thresholds.isEmpty()) {
return AjaxResult.error("修改列表不能为空");
}
return toAjax(thingsThresholdService.updateThresholdBatch(thresholds));
}
/**

View File

@ -19,12 +19,16 @@ import com.fastbee.report.period.ReportPeriodResolver;
import com.fastbee.report.period.TimeWindow;
import com.fastbee.report.record.ReportRecord;
import com.fastbee.report.record.ReportRecordService;
import com.fastbee.report.data.MetricData;
import com.fastbee.report.model.Statistics;
import com.fastbee.report.renderer.ReportRenderer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
@ -111,6 +115,15 @@ public class ReportAsyncExecutor {
ReportAnalyzer analyzer = analyzerFactory.get(context.getCategory());
AnalysisResult analysis = analyzer.analyze(context, request);
// 无有效监测数据时终止生成,避免导出空白文档
if (isNoEffectiveData(context, analysis)) {
log.warn("报告生成中止:时间范围内无有效监测数据 reportNo={} deviceId={}",
reportNo, request.getDeviceId());
reportRecordService.markFailed(record, "所选时间范围内无有效监测数据,无法生成报告");
return;
}
ReportModel model = reportAssembler.assemble(context, analysis);
byte[] docBytes = reportRenderer.render(model);
@ -129,6 +142,24 @@ public class ReportAsyncExecutor {
}
}
/**
* 判断本次报告是否无任何有效监测数据,避免导出空白文档。
*
* <p>判定口径:① 时序数据列表为空(设备完全未上报);② 所有物模型统计 count 均为 0
* (上报数据均无效或未落在统计窗口内);③ 未配置任何监测物模型(无统计数据)。</p>
*/
private boolean isNoEffectiveData(ReportContext context, AnalysisResult analysis) {
List<MetricData> metricDataList = context.getMetricDataList();
if (metricDataList == null || metricDataList.isEmpty()) {
return true;
}
Map<String, Statistics> statisticsMap = analysis.getStatistics();
if (statisticsMap == null || statisticsMap.isEmpty()) {
return true;
}
return statisticsMap.values().stream().noneMatch(s -> s.getCount() > 0);
}
private void validate(ReportRequest request) {
if (request == null || request.getDeviceId() == null) {
throw new ReportException("请选择设备");

View File

@ -53,4 +53,20 @@ public interface IThingsThresholdService extends IService<ThingsThreshold> {
* @return 影响行数
*/
int deleteThresholdByIds(Long[] ids);
/**
* 批量新增阈值
*
* @param thresholds 阈值列表
* @return 影响行数
*/
int insertThresholdBatch(List<ThingsThreshold> thresholds);
/**
* 批量修改阈值
*
* @param thresholds 阈值列表
* @return 影响行数
*/
int updateThresholdBatch(List<ThingsThreshold> thresholds);
}

View File

@ -8,6 +8,7 @@ import com.fastbee.report.mapper.ThingsThresholdMapper;
import com.fastbee.report.service.IThingsThresholdService;
import com.fastbee.report.threshold.ThingsThreshold;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.List;
@ -63,4 +64,24 @@ public class ThingsThresholdServiceImpl extends ServiceImpl<ThingsThresholdMappe
public int deleteThresholdByIds(Long[] ids) {
return baseMapper.deleteBatchIds(Arrays.asList(ids));
}
@Override
@Transactional(rollbackFor = Exception.class)
public int insertThresholdBatch(List<ThingsThreshold> thresholds) {
if (thresholds == null || thresholds.isEmpty()) {
return 0;
}
boolean ok = saveBatch(thresholds);
return ok ? thresholds.size() : 0;
}
@Override
@Transactional(rollbackFor = Exception.class)
public int updateThresholdBatch(List<ThingsThreshold> thresholds) {
if (thresholds == null || thresholds.isEmpty()) {
return 0;
}
boolean ok = updateBatchById(thresholds);
return ok ? thresholds.size() : 0;
}
}

View File

@ -1,5 +1,7 @@
package com.fastbee.report.threshold;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
@ -48,13 +50,17 @@ public class ThingsThreshold {
private String unit;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField(fill = FieldFill.INSERT)
private Date createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
}

View File

@ -57,4 +57,10 @@ public class SubDeviceListVO {
@ApiModelProperty("在线状态")
private Integer status;
/**
* 基准点设备id(iot_device.baseline_device_id):同一子产品下作为相对测量基准的设备;0/null 表示绝对量测量
*/
@ApiModelProperty("基准点设备id")
private Long baselineDeviceId;
}

View File

@ -202,6 +202,15 @@ public interface IDeviceService {
*/
public AjaxResult updateDevice(Device device);
/**
* 批量更新设备基准点(仅写入 baseline_device_id)
* <p>同一子产品下通过单选确定一个基准点设备,组内其余设备指向该基准点;基准点设备自身写 0。</p>
*
* @param devices 仅包含 deviceId 与 baselineDeviceId 的设备列表
* @return 影响行数
*/
public int updateDeviceBaselineBatch(List<Device> devices);
/**
* 更新设备状态和定位
*

View File

@ -325,6 +325,26 @@ public class DeviceServiceImpl implements IDeviceService {
return AjaxResult.success("修改成功", 1);
}
/**
* 批量更新设备基准点(仅写入 baseline_device_id)
* <p>组内其余设备指向基准点设备,基准点设备自身写 0。循环复用 updateDevice 的 baselineDeviceId 分支。</p>
*/
@Override
public int updateDeviceBaselineBatch(List<Device> devices) {
if (devices == null || devices.isEmpty()) {
return 0;
}
int rows = 0;
for (Device device : devices) {
if (device.getDeviceId() == null) {
continue;
}
device.setUpdateTime(DateUtils.getNowDate());
rows += deviceMapper.updateDevice(device);
}
return rows;
}
@Caching(evict = {
@CacheEvict(cacheNames = "Device", key = "'selectDeviceBySerialNumber:' + #device.getSerialNumber()"),
// @CacheEvict(cacheNames = "Device", key = "'selectDeviceByDeviceId:' +
@ -1024,7 +1044,10 @@ public class DeviceServiceImpl implements IDeviceService {
}
}
fillShortListLatestReportTime(list, device.getEntrance());
log.warn("当前selectDeviceShortList查询接口耗时: {}", System.currentTimeMillis() - startTime);
long took = System.currentTimeMillis() - startTime;
if (took > 1000L) {
log.warn("selectDeviceShortList took: {}", took);
}
return list;
}

View File

@ -32,6 +32,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="subProductId" column="sub_product_id"/>
<result property="subProductName" column="sub_product_name"/>
<result property="status" column="status"/>
<result property="baselineDeviceId" column="baseline_device_id"/>
<result property="id" column="id" />
</resultMap>
@ -51,6 +52,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectGatewayList" parameterType="com.fastbee.iot.domain.SubGateway" resultMap="GatewaySubDeviceListResult">
select ga.id, ga.gw_device_id, ga.sub_device_id, ga.slave_id, ga.create_time,
d.device_name, d.serial_number, d.product_id sub_product_id, d.status,
d.baseline_device_id,
p.product_name as sub_product_name
from iot_sub_gateway ga
inner join iot_device d on ga.sub_device_id = d.device_id

View File

@ -167,7 +167,7 @@ INSERT IGNORE INTO `sys_menu`
(menu_id, menu_name, parent_id, order_num, `path`, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES
(3610, '历史报表', 3343, 4, 'reportHistory', 'dataCenter/reportHistory', NULL, 1, 0, 'C', '0', '0', 'iot:report:list', 'documentation', 'admin', NOW(), 'admin', NOW(), '设备监测报告历史报表'),
(3611, '报表生成', 3610, 1, '', NULL, NULL, 1, 0, 'F', '0', '0', 'iot:report:generate', '#', 'admin', NOW(), 'admin', NOW(), '生成并导出报表');
(3611, '报表生成', 3610, 1, '', NULL, NULL, 1, 0, 'F', '0', '0', 'iot:report:generate', '#', 'admin', NOW(), 'admin', NOW(), '生成并导出分析报告');
INSERT IGNORE INTO `sys_menu_translate` (id, zh_cn, en_us) VALUES
(3610, '历史报表', 'Report history'),

View File

@ -168,6 +168,15 @@ export function updateDevice(data) {
});
}
// 批量更新设备基准点(仅写入 baselineDeviceId)
export function updateDeviceBaselineBatch(data) {
return request({
url: '/iot/device/baseline/batch',
method: 'post',
data: data,
});
}
// 删除设备
export function delDevice(deviceId) {
return request({

View File

@ -1,6 +1,6 @@
import request from '@/utils/request';
// 生成并导出报表(设备 + 时间范围)
// 生成并导出分析报告(设备 + 时间范围)
export function generateDeviceReport(data) {
return request({
url: '/iot/report/generate',

View File

@ -17,7 +17,7 @@ export function getThreshold(id) {
})
}
// 新增阈值
// 新增阈值(批量,data 为 ThingsThreshold 数组)
export function addThreshold(data) {
return request({
url: '/iot/threshold',
@ -26,7 +26,7 @@ export function addThreshold(data) {
})
}
// 修改阈值
// 修改阈值(批量,data 为 ThingsThreshold 数组)
export function updateThreshold(data) {
return request({
url: '/iot/threshold',

View File

@ -321,7 +321,7 @@
>
<i v-if="!reportLoading" class="el-icon-document"></i>
<i v-else class="el-icon-loading"></i>
<span>生成并导出报表</span>
<span>生成并导出分析报告</span>
</button>
</div>
</el-form>
@ -1553,7 +1553,7 @@ export default {
format: 'WORD',
})
.then((res) => {
if (res.code === 200) {
if (res && res.code === 200) {
// 使用弹框提示用户
this.$alert('报表正在后台生成中,生成完成后可在历史报表中下载', '提交成功', {
confirmButtonText: '查看历史报表',

View File

@ -38,7 +38,7 @@
size="mini"
:loading="reportLoading"
@click="handleGenerateReport"
>生成并导出报表</el-button>
>生成并导出分析报告</el-button>
</el-form-item>
</el-form>
</el-card>
@ -333,7 +333,7 @@ export default {
format: 'WORD',
})
.then((res) => {
if (res.code === 200) {
if (res && res.code === 200) {
// 使用弹框提示用户
this.$alert('报表正在后台生成中,生成完成后可在历史报表中下载', '提交成功', {
confirmButtonText: '查看历史报表',

View File

@ -29,6 +29,9 @@
<el-button type="success" icon="el-icon-download" size="mini" :disabled="!canExportNormal" @click="handleExportNormal" v-loading="exportLoading">
{{ $t('dataCenter.history.384934-24') }}
</el-button>
<el-button type="warning" icon="el-icon-document" size="mini" :disabled="!canExportNormal" @click="handleExportReport" v-loading="reportLoading">
导出分析报告
</el-button>
</el-form-item>
</el-form>
@ -78,6 +81,7 @@
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleGwQuery">查询</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="handleReset">重置</el-button>
<el-button type="success" icon="el-icon-download" size="mini" :disabled="!gwHasExportableData" @click="handleGwExport" v-loading="exportLoading">导出Excel</el-button>
<el-button type="warning" icon="el-icon-document" size="mini" :disabled="!gwHasExportableData" @click="handleExportReport" v-loading="reportLoading">导出分析报告</el-button>
</el-form-item>
</el-form>
@ -127,6 +131,7 @@ import {
getSubDeviceComparativeHistory,
exportSubDeviceComparativeHistory,
} from '@/api/data/center.js';
import { generateDeviceReport } from '@/api/iot/report';
import { saveAs } from 'file-saver';
import { blobValidate } from '@/utils/ruoyi';
@ -289,6 +294,7 @@ export default {
},
loading: false,
exportLoading: false,
reportLoading: false,
hasQueried: false,
resultMode: '',
independentYAxis: true,
@ -459,6 +465,52 @@ export default {
this.disposeChart();
},
methods: {
handleExportReport() {
let reportDeviceId = this.device ? this.device.deviceId : null;
if (!reportDeviceId) {
this.$message.warning('请先选择设备');
return;
}
const range = this.isGatewayLike ? this.gwQueryParams.dayDaterange : this.queryParams.dayDaterange;
if (!range || range.length < 2) {
this.$message.warning('请选择时间范围');
return;
}
// 网关设备需选定单个子设备后才能生成报告(网关本身不可生成)
if (this.isGatewayLike) {
const subIds = this.gwQueryParams.subDeviceIds;
if (Array.isArray(subIds) && subIds.length === 1) {
reportDeviceId = subIds[0];
} else {
this.$message.warning('网关设备请选择单个子设备后生成报告');
return;
}
}
this.reportLoading = true;
const payload = {
deviceId: reportDeviceId,
startTime: moment(range[0]).format('YYYY-MM-DD HH:mm:ss'),
endTime: moment(range[1]).format('YYYY-MM-DD HH:mm:ss'),
format: 'WORD',
};
generateDeviceReport(payload)
.then((res) => {
if (res.code === 200) {
this.$alert('报表正在后台生成中,生成完成后可在历史报表中下载', '提交成功', {
confirmButtonText: '查看历史报表',
type: 'success',
callback: action => {
if (action === 'confirm') {
this.$router.push({ path: '/dataCenter/reportHistory' }).catch(() => {});
}
}
});
}
})
.finally(() => {
this.reportLoading = false;
});
},
disposeChart() {
if (this.chartInstance) {
this.chartInstance.dispose();

View File

@ -47,15 +47,30 @@
<el-tag :type="statusType(scope.row.status)" size="mini">{{ statusText(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<el-table-column label="操作" width="120" align="center" fixed="right">
<template slot-scope="scope">
<el-button
v-if="scope.row.status === 'SUCCESS'"
type="success"
icon="el-icon-download"
size="mini"
:disabled="scope.row.status !== 'SUCCESS'"
@click="handleDownload(scope.row)"
>下载</el-button>
<el-button
v-if="scope.row.status === 'FAILED'"
type="danger"
plain
icon="el-icon-warning-outline"
size="mini"
@click="handleShowError(scope.row)"
>失败原因</el-button>
<el-button
v-if="scope.row.status === 'CREATING'"
plain
icon="el-icon-refresh"
size="mini"
@click="getList()"
>刷新</el-button>
</template>
</el-table-column>
</el-table>
@ -68,14 +83,40 @@
@pagination="getList"
/>
</el-card>
<!-- 失败原因 -->
<el-dialog title="报告生成失败原因" :visible.sync="errorVisible" width="520px" append-to-body>
<div class="report-error-detail">
<div class="detail-item">
<span class="label">报告编号:</span>{{ errorRow.reportNo || '-' }}
</div>
<div class="detail-item">
<span class="label">设备名称:</span>{{ errorRow.deviceName || '-' }}
</div>
<div class="detail-item">
<span class="label">时间范围:</span>{{ errorRow.startTime || '-' }} ~ {{ errorRow.endTime || '-' }}
</div>
<div class="detail-item">
<span class="label">失败原因:</span>
</div>
<pre class="error-message">{{ errorRow.errorMessage || '未记录失败原因,请联系管理员查看服务端日志' }}</pre>
</div>
<span slot="footer">
<el-button size="mini" type="primary" @click="errorVisible = false">关闭</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import { saveAs } from 'file-saver';
import moment from 'moment';
import { listReportRecords, downloadReportRecord } from '@/api/iot/report';
import { blobValidate } from '@/utils/ruoyi';
/** 报告保留天数,与后端 LogCleanTask#cleanLogs 默认保留天数一致 */
const REPORT_RETENTION_DAYS = 15;
export default {
name: 'ReportHistory',
data() {
@ -83,6 +124,10 @@ export default {
loading: false,
reportList: [],
total: 0,
/** 生成中记录轮询定时器 */
pollTimer: null,
errorVisible: false,
errorRow: {},
queryParams: {
pageNum: 1,
pageSize: 10,
@ -107,18 +152,44 @@ export default {
mounted() {
this.getList();
},
beforeDestroy() {
this.stopPolling();
},
methods: {
getList() {
this.loading = true;
/** silent=true 时不展示 loading,用于轮询刷新 */
getList(silent) {
if (!silent) {
this.loading = true;
}
listReportRecords(this.queryParams)
.then((res) => {
this.reportList = res.rows || [];
this.total = res.total || 0;
})
.finally(() => {
this.loading = false;
if (!silent) {
this.loading = false;
}
this.schedulePolling();
});
},
/** 存在生成中记录时每 10s 轮询,全部转为终态后自动停止 */
schedulePolling() {
this.stopPolling();
const hasCreating = this.reportList.some((item) => item.status === 'CREATING');
if (!hasCreating) {
return;
}
this.pollTimer = setTimeout(() => {
this.getList(true);
}, 10000);
},
stopPolling() {
if (this.pollTimer) {
clearTimeout(this.pollTimer);
this.pollTimer = null;
}
},
handleQuery() {
this.queryParams.pageNum = 1;
this.getList();
@ -127,6 +198,17 @@ export default {
this.resetForm('queryForm');
this.handleQuery();
},
/** 报告无 expire_time 字段,按 create_time + 保留天数判断是否已过期 */
isExpired(row) {
if (!row || !row.createTime) {
return false;
}
const created = moment(row.createTime, 'YYYY-MM-DD HH:mm:ss', true);
if (!created.isValid()) {
return false;
}
return moment().diff(created, 'days') >= REPORT_RETENTION_DAYS;
},
handleDownload(row) {
if (this.isExpired(row)) {
this.$message.warning('报告已过期');
@ -148,6 +230,10 @@ export default {
this.$message.error('下载失败,请稍后重试');
});
},
handleShowError(row) {
this.errorRow = row || {};
this.errorVisible = true;
},
categoryText(code) {
const map = {
CRACK_METER: '裂缝计',
@ -179,4 +265,24 @@ export default {
.data-center-report-history .el-card {
min-height: calc(100vh - 110px);
}
.report-error-detail .detail-item {
margin-bottom: 6px;
font-size: 13px;
}
.report-error-detail .label {
color: #909399;
}
.report-error-detail .error-message {
margin: 0;
padding: 8px 10px;
background: #f5f7fa;
border-radius: 4px;
color: #f56c6c;
font-size: 13px;
font-family: inherit;
white-space: pre-wrap;
word-break: break-all;
max-height: 240px;
overflow: auto;
}
</style>

View File

@ -257,7 +257,9 @@ export default {
this.$modal.msgError('请先保存产品信息');
return;
}
const tasks = [];
const toAdd = [];
const toUpdate = [];
const toDelete = [];
this.rows.forEach((row) => {
const payload = {
productId: this.toNum(this.productId),
@ -274,14 +276,18 @@ export default {
};
if (this.hasAnyValue(row)) {
if (row.id) {
tasks.push(updateThreshold(Object.assign({ id: row.id }, payload)));
toUpdate.push(Object.assign({ id: row.id }, payload));
} else {
tasks.push(addThreshold(payload));
toAdd.push(payload);
}
} else if (row.id) {
tasks.push(delThreshold(row.id));
toDelete.push(row.id);
}
});
const tasks = [];
if (toAdd.length) tasks.push(addThreshold(toAdd));
if (toUpdate.length) tasks.push(updateThreshold(toUpdate));
if (toDelete.length) tasks.push(delThreshold(toDelete.join(',')));
if (!tasks.length) {
this.$modal.msgWarning('没有需要保存的阈值');
return;

View File

@ -54,8 +54,8 @@
</el-button>
</el-input>
</el-form-item>
<!-- 基准点设备:相对测量类设备(静力水准等)需指定基准点,留空表示绝对量测量 -->
<el-form-item v-if="form.deviceType != 2" prop="baselineDeviceId">
<!-- 基准点设备:相对测量类设备(静力水准等)需指定基准点,留空表示绝对量测量。不做 deviceType 限制,任意子设备均可配置 -->
<el-form-item prop="baselineDeviceId">
<template slot="label">
<el-tooltip placement="top">
<div slot="content">
@ -255,7 +255,7 @@
<!-- 设备阈值(优先级高于产品阈值,留空则继承产品阈值) -->
<el-tab-pane name="deviceThreshold" :disabled="form.deviceId == 0" v-if="form.deviceType !== 3" lazy>
<span slot="label">{{ $t('device.device-edit.148398-85') }}</span>
<span slot="label"><span style="color: red">* </span>{{ $t('device.device-edit.148398-85') }}</span>
<threshold-config
ref="deviceThreshold"
:product-id="form.productId"

View File

@ -39,6 +39,13 @@
<el-input style="width: 120px; text-align: center" :disabled="!isSet" v-model="scope.row.slaveId" size="small" :placeholder="$t('device.sub.083943-2')"></el-input>
</template>
</el-table-column>
<el-table-column label="基准点" align="center" width="90px" :render-header="renderBaselineHeader">
<template slot-scope="scope">
<el-radio v-model="baselineMap[scope.row.subProductId]" :label="scope.row.subDeviceId" :disabled="!isSet">
<span></span>
</el-radio>
</template>
</el-table-column>
<el-table-column :label="$t('device.sub.onlineStatus')" align="center" prop="status">
<template slot-scope="scope">
<dict-tag :options="dict.type.iot_device_status" :value="scope.row.status" />
@ -69,6 +76,7 @@
<script>
import { listGateway, delGateway, editGatewayBatch } from '@/api/iot/gateway';
import { updateDeviceBaselineBatch } from '@/api/iot/device';
import subDeviceList from './sub-device-list';
import subDeviceCreate from './sub-device-create';
@ -119,6 +127,8 @@ export default {
isSet: false,
// 子产品行合并信息
productSpans: [],
// 各子产品的基准点设备id(同子产品单选),key=subProductId,value=subDeviceId
baselineMap: {},
};
},
@ -143,8 +153,54 @@ export default {
this.total = response.total;
this.loading = false;
this.computeProductSpans();
this.computeBaselineMap();
});
},
/**
* 由后端回查数据推导各子产品的当前基准点。
* 约定:基准点设备 baselineDeviceId=0;同组其余设备 baselineDeviceId=基准点设备id。
* 因此某子产品下被多数设备引用的 deviceId 即为基准点。
*/
computeBaselineMap() {
const map = {};
const list = this.deviceList || [];
const groups = {};
list.forEach((d) => {
const pid = d.subProductId;
if (pid == null) return;
(groups[pid] = groups[pid] || []).push(d);
});
Object.keys(groups).forEach((pid) => {
const refCount = {};
groups[pid].forEach((d) => {
const base = d.baselineDeviceId;
if (base && base !== 0) {
refCount[base] = (refCount[base] || 0) + 1;
}
});
let baseline = null;
let max = 0;
Object.keys(refCount).forEach((devId) => {
if (refCount[devId] > max) {
max = refCount[devId];
baseline = Number(devId);
}
});
map[pid] = baseline;
});
this.baselineMap = map;
},
/** 基准点列头带说明 */
renderBaselineHeader(h) {
return h(
'span',
{},
[
'基准点 ',
h('i', { class: 'el-icon-question', style: 'cursor:help;color:#909399;' }),
]
);
},
computeProductSpans() {
const spans = [];
const list = this.deviceList || [];
@ -238,11 +294,39 @@ export default {
/**保存子设备地址设置*/
saveSetting() {
this.isSet = !this.isSet;
editGatewayBatch(this.deviceList).then((response) => {
const baselinePayload = this.buildBaselinePayload();
const tasks = [editGatewayBatch(this.deviceList)];
if (baselinePayload.length > 0) {
tasks.push(updateDeviceBaselineBatch(baselinePayload));
}
Promise.all(tasks).then(() => {
this.getList();
this.$modal.msgSuccess(this.$t('saveSuccess'));
});
},
/**
* 依据 baselineMap 组装基准点回写数据。
* 同一子产品下:基准点设备 baselineDeviceId=0;其余设备 baselineDeviceId=基准点设备id。
*/
buildBaselinePayload() {
const groups = {};
(this.deviceList || []).forEach((d) => {
const pid = d.subProductId;
if (pid == null) return;
(groups[pid] = groups[pid] || []).push(d);
});
const payload = [];
Object.keys(groups).forEach((pid) => {
const baseline = this.baselineMap[pid];
groups[pid].forEach((d) => {
payload.push({
deviceId: d.subDeviceId,
baselineDeviceId: d.subDeviceId === baseline ? 0 : (baseline || 0),
});
});
});
return payload;
},
cancelSetting() {
this.isSet = !this.isSet;
},